Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions _includes/body-scripts.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<canvas id="lightning-bg" aria-hidden="true"></canvas>
<script src="/assets/lightning-bg.js" defer></script>
263 changes: 263 additions & 0 deletions assets/lightning-bg.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
(() => {
const canvas = document.getElementById("lightning-bg");
if (!canvas) return;

const reduceMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;

const gl =
canvas.getContext("webgl2", {
antialias: false,
alpha: true,
premultipliedAlpha: false,
powerPreference: "low-power",
}) ||
canvas.getContext("webgl", {
antialias: false,
alpha: true,
premultipliedAlpha: false,
powerPreference: "low-power",
});

if (!gl) return;

const isWebGL2 = typeof WebGL2RenderingContext !== "undefined" &&
gl instanceof WebGL2RenderingContext;

const vsSource = isWebGL2
? `#version 300 es
in vec2 a_position;
void main() { gl_Position = vec4(a_position, 0.0, 1.0); }`
: `attribute vec2 a_position;
void main() { gl_Position = vec4(a_position, 0.0, 1.0); }`;

const fsBody = `
vec2 res = u_resolution;
vec2 uv = (gl_FragCoord.xy - 0.5 * res) / res.y;
float t = u_time;

vec3 col = vec3(0.0);

// Three bolts, different speeds, seeds and tint colours
col += bolt(uv, t, 0.0, 1.00, vec3(0.15, 1.00, 0.65)) * 1.00;
col += bolt(uv, t * 0.78 + 7.3, 2.1, 0.75, vec3(0.00, 0.85, 1.00)) * 0.85;
col += bolt(uv, t * 1.14 + 3.7, 4.8, 1.25, vec3(0.35, 1.00, 0.25)) * 0.70;

// Rare sharp flash — brightens every few seconds
float flashPhase = fract(t * 0.17);
float flash = smoothstep(0.85, 1.0, flashPhase) *
(1.0 - smoothstep(0.97, 1.0, flashPhase));
col += flash * vec3(0.3, 1.0, 0.6) * 0.18;

// Soft horizon haze near the bolts
float haze = fbm(uv * 2.5 + vec2(t * 0.08, 0.0));
col += haze * vec3(0.0, 0.05, 0.03);

// Radial falloff — darker at edges
float vig = 1.0 - 0.35 * dot(uv, uv);
col *= vig;

// Overall dim now that we're rendered behind the page rather than
// screen-blended on top — keeps the palette feeling restrained.
col *= 0.55;

// Soft tonemap so highlights hold their colour
col = col / (1.0 + col);
col = pow(col, vec3(0.9));

// Sit on a slightly lit near-black so the viewport base tone matches
// the page's html background rather than flashing pure black.
vec3 base = vec3(0.006, 0.012, 0.020);
col = max(col, base);

outColor = vec4(col, 1.0);
`;

const fsShared = `
precision highp float;

float hash21(vec2 p) {
p = fract(p * vec2(233.34, 851.73));
p += dot(p, p + 23.45);
return fract(p.x * p.y);
}

float vnoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
float a = hash21(i);
float b = hash21(i + vec2(1.0, 0.0));
float c = hash21(i + vec2(0.0, 1.0));
float d = hash21(i + vec2(1.0, 1.0));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}

float fbm(vec2 p) {
float v = 0.0;
float a = 0.5;
for (int i = 0; i < 4; i++) {
v += a * vnoise(p);
p = p * 2.03 + vec2(17.1, 31.7);
a *= 0.5;
}
return v;
}

// Single swooping lightning arc
// uv : screen coord (-aspect..aspect, -0.5..0.5)
// t : time
// seed : per-bolt phase offset
// speed : horizontal drift speed
// tint : base colour
vec3 bolt(vec2 uv, float t, float seed, float speed, vec3 tint) {
// Domain warp for swoosh
vec2 q = uv;
q.x += t * speed * 0.12 + seed;
float w1 = fbm(q * 1.2 + seed * 3.1);
float w2 = fbm(q * 2.8 - t * 0.3 + seed);
vec2 warped = uv + vec2(0.0, (w1 - 0.5) * 0.55 + (w2 - 0.5) * 0.18);

// Gentle sine curves stacked to feel electric
float y = warped.y;
y += 0.18 * sin(uv.x * 2.4 + t * 0.9 + seed);
y += 0.09 * sin(uv.x * 5.1 - t * 0.6 + seed * 2.0);
y += 0.04 * sin(uv.x * 11.0 + t * 1.3 + seed * 3.0);

// Vertical offset per bolt
y -= (seed * 0.07) - 0.05;

float d = abs(y);

// Hot core — thin and bright
float core = 0.0015 / (d + 0.002);
// Inner glow
float inner = 0.012 / (d + 0.025);
// Outer halo
float outer = 0.08 / (d + 0.22);

// Fade the bolt along x so it feels like it has a tail
float tail = smoothstep(-1.4, -0.3, uv.x) *
(1.0 - smoothstep(0.6, 1.4, uv.x));

// Flicker along the length
float flick = 0.85 +
0.15 * sin(uv.x * 24.0 + t * 11.0 + seed * 9.0);
flick *= 0.85 + 0.15 * fbm(vec2(uv.x * 6.0 + t * 2.0, seed));

vec3 col = tint * (inner + outer * 0.6) * flick * tail;
col += vec3(1.0) * core * flick * tail; // white hot centre
return col;
}
`;

const fsSource = isWebGL2
? `#version 300 es
${fsShared}
uniform vec2 u_resolution;
uniform float u_time;
out vec4 outColor;
void main() {
${fsBody}
}`
: `
${fsShared}
uniform vec2 u_resolution;
uniform float u_time;
#define outColor gl_FragColor
void main() {
${fsBody}
}`;

const compile = (type, source) => {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.warn("Lightning shader compile failed:", gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
};

const vs = compile(gl.VERTEX_SHADER, vsSource);
const fs = compile(gl.FRAGMENT_SHADER, fsSource);
if (!vs || !fs) return;

const program = gl.createProgram();
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.warn("Lightning program link failed:", gl.getProgramInfoLog(program));
return;
}
gl.useProgram(program);

const posLoc = gl.getAttribLocation(program, "a_position");
const resLoc = gl.getUniformLocation(program, "u_resolution");
const timeLoc = gl.getUniformLocation(program, "u_time");

const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
gl.STATIC_DRAW,
);
gl.enableVertexAttribArray(posLoc);
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);

const pixelRatioCap = 1.0; // half-res on retina; soft bolts hide it
let width = 0;
let height = 0;

const resize = () => {
const ratio = Math.min(window.devicePixelRatio || 1, pixelRatioCap);
const w = Math.max(1, Math.floor(window.innerWidth * ratio));
const h = Math.max(1, Math.floor(window.innerHeight * ratio));
if (w === width && h === height) return;
width = w;
height = h;
canvas.width = w;
canvas.height = h;
gl.viewport(0, 0, w, h);
};

resize();
window.addEventListener("resize", resize, { passive: true });

const start = performance.now();
let paused = false;
let rafId = 0;

const render = (now) => {
rafId = 0;
if (paused) return;
const t = (now - start) * 0.001;
gl.uniform2f(resLoc, width, height);
gl.uniform1f(timeLoc, t);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
if (!reduceMotion) {
rafId = requestAnimationFrame(render);
}
};

const schedule = () => {
if (!rafId && !paused) rafId = requestAnimationFrame(render);
};

document.addEventListener("visibilitychange", () => {
paused = document.hidden;
if (paused && rafId) {
cancelAnimationFrame(rafId);
rafId = 0;
} else {
schedule();
}
});

schedule();
})();
43 changes: 35 additions & 8 deletions css/theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,13 @@
:root {
--width-content: 1100px;

--color-bg: #0a0a0a;
--color-card-bg: #12101a;
--color-accent: #0a1808;
// heropatterns "Plus" - geometric neon crosses
--body-background: #0a1a0a
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='60' viewBox='0 0 60 60'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z' fill='%2300ff00' fill-opacity='0.07'/%3E%3C/svg%3E");
--body-background-alt: #122818
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='60' viewBox='0 0 60 60'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z' fill='%2300ff00' fill-opacity='0.07'/%3E%3C/svg%3E");
--color-bg: #060608;
--color-card-bg: #0a0812;
--color-accent: #051005;
// Page surfaces stay transparent so the lightning canvas reads through
// from behind; the base colour lives on <html>.
--body-background: transparent;
--body-background-alt: transparent;
--color-text: #00ff00;
--color-link: #00ffff;
--color-tint: #002a2a;
Expand All @@ -46,6 +45,15 @@
--link-decoration-style: double;
}

html {
background-color: #02060a;
min-height: 100%;
}

body.design-system {
background: transparent;
}

h1,
h2,
h3,
Expand All @@ -69,3 +77,22 @@ header {
justify-self: center;
}
}

// Full-screen WebGL lightning background. Sits behind everything — cards,
// image/video backgrounds, nav — so it feels like part of the page's
// background image rather than an overlay.
#lightning-bg {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: -1;
will-change: transform;
}

@media (prefers-reduced-motion: reduce) {
#lightning-bg {
opacity: 0.5;
}
}
Loading