<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sky Hero — Shader Portfolio</title>
<style>
:root {
--ink: #ffffff;
--ink-soft: rgba(255, 255, 255, 0.85);
--ink-faint: rgba(255, 255, 255, 0.65);
--glass: rgba(255, 255, 255, 0.14);
--glass-border: rgba(255, 255, 255, 0.35);
--pill-dark: #0e1526;
--card-ink: #101828;
--card-soft: #475467;
--headline-shadow: 0 3px 30px rgba(9, 40, 90, 0.35);
--nav-shadow: 0 8px 32px rgba(9, 40, 90, 0.18);
}
html[data-theme="dark"] {
--ink: #f2f6ff;
--ink-soft: rgba(242, 246, 255, 0.85);
--ink-faint: rgba(242, 246, 255, 0.6);
--glass: rgba(10, 16, 34, 0.45);
--glass-border: rgba(150, 170, 220, 0.22);
--headline-shadow: 0 3px 30px rgba(0, 0, 0, 0.5);
--nav-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
color: var(--ink);
overflow-x: hidden;
background: #1877d2;
transition: background 0.9s ease;
}
html[data-theme="dark"] body { background: #060a18; }
#sky-canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; z-index: 0; }
.sky-fallback { position: fixed; inset: 0; z-index: 0; display: none;
background: linear-gradient(180deg, #0d5fc4 0%, #2b8ae8 55%, #7cc0f4 100%); }
html[data-theme="dark"] .sky-fallback { background: linear-gradient(180deg, #050914 0%, #0b1230 60%, #1a2350 100%); }
.no-webgl .sky-fallback { display: block; }
/* Fade the sky into a soft blur at the viewport edges. */
.sky-edge-blur {
position: fixed;
left: 0;
right: 0;
z-index: 1;
pointer-events: none;
-webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px);
}
.sky-edge-blur.top {
top: 0;
height: 18svh;
-webkit-mask-image: linear-gradient(to bottom, #000, transparent);
mask-image: linear-gradient(to bottom, #000, transparent);
}
.sky-edge-blur.bottom {
bottom: 0;
height: 22svh;
-webkit-mask-image: linear-gradient(to top, #000, transparent);
mask-image: linear-gradient(to top, #000, transparent);
}
/* ---------- Nav ---------- */
.nav {
position: fixed;
top: 18px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex;
align-items: center;
gap: clamp(14px, 3vw, 34px);
padding: 12px 14px 12px 22px;
border-radius: 18px;
background: var(--glass);
border: 1px solid var(--glass-border);
backdrop-filter: blur(18px) saturate(1.3);
-webkit-backdrop-filter: blur(18px) saturate(1.3);
box-shadow: var(--nav-shadow);
white-space: nowrap;
opacity: 0;
animation: fade-down 0.9s ease 0.1s forwards;
}
.nav-logo { font-weight: 800; font-size: 17px; display: flex; align-items: center; gap: 8px; color: var(--ink); text-decoration: none; }
.nav-logo span { font-size: 15px; }
.nav a.link { color: var(--ink-soft); text-decoration: none; font-size: 14.5px; font-weight: 500; transition: color 0.2s; }
.nav a.link:hover { color: var(--ink); }
.nav .login {
background: var(--glass);
border: 1px solid var(--glass-border);
color: var(--ink);
padding: 9px 18px;
border-radius: 12px;
font-size: 14.5px;
font-weight: 600;
text-decoration: none;
transition: background 0.2s;
}
.nav .login:hover { background: rgba(255, 255, 255, 0.25); }
html[data-theme="dark"] .nav .login:hover { background: rgba(120, 150, 220, 0.2); }
@media (max-width: 760px) {
.nav a.link { display: none; }
.nav a.link:nth-child(2) { display: inline; }
}
/* ---------- Hero ---------- */
.hero {
position: relative;
z-index: 2;
min-height: 100svh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 110px 20px 60px;
}
h1 {
font-weight: 700;
font-size: clamp(2.4rem, 6.5vw, 5.2rem);
line-height: 1.06;
letter-spacing: -0.025em;
text-shadow: var(--headline-shadow);
max-width: 14ch;
}
.line { display: block; overflow: hidden; padding-bottom: 0.1em; margin-bottom: -0.1em; }
.word {
display: inline-block;
transform: translateY(115%);
animation: rise 1s cubic-bezier(0.22, 0.68, 0.14, 1) forwards;
animation-delay: var(--d, 0s);
}
/* ---------- Floating cards ---------- */
.cards {
position: relative;
width: min(680px, 92vw);
height: clamp(240px, 34vw, 330px);
margin-top: clamp(28px, 5vh, 56px);
opacity: 0;
animation: fade-up 1s ease 0.9s forwards;
}
.card {
position: absolute;
background: #ffffff;
border-radius: 20px;
padding: 20px;
box-shadow: 0 24px 60px rgba(8, 35, 80, 0.28), 0 4px 14px rgba(8, 35, 80, 0.12);
text-align: left;
color: var(--card-ink);
animation: bob 7s ease-in-out infinite;
}
.card h3 { font-size: 17px; font-weight: 700; line-height: 1.35; }
.card p { font-size: 12.5px; color: var(--card-soft); margin-top: 4px; }
.card .label { font-size: 10.5px; letter-spacing: 0.14em; text-transform: uppercase; color: #98a2b3; font-weight: 600; }
.card .big { font-size: clamp(26px, 4vw, 38px); font-weight: 800; letter-spacing: -0.02em; }
.pill {
display: inline-flex;
align-items: center;
gap: 7px;
background: var(--pill-dark);
color: #fff;
border: none;
font-size: 13.5px;
font-weight: 600;
padding: 10px 16px;
border-radius: 999px;
cursor: pointer;
margin-top: 14px;
text-decoration: none;
transition: transform 0.2s;
}
.pill:hover { transform: scale(1.05); }
.orb {
width: 38px; height: 38px;
border-radius: 999px;
background: var(--pill-dark);
color: #fff;
display: grid;
place-items: center;
font-size: 15px;
cursor: pointer;
transition: transform 0.2s;
}
.orb:hover { transform: scale(1.1); }
.card-a { left: 0%; top: 8%; width: 250px; transform: rotate(-7deg); z-index: 1; animation-delay: 0s; }
.card-b { left: 50%; top: 22%; width: 280px; transform: translateX(-50%) rotate(2deg); z-index: 3; background: #10182b; color: #fff; animation-delay: 1.2s; }
.card-b p { color: #98a2b3; }
.card-b .row { display: flex; justify-content: space-between; align-items: flex-end; margin-top: 18px; }
.card-b .row span { font-size: 12px; color: #98a2b3; }
.card-b .orb { background: #232f4b; }
.card-c { right: 0%; top: 12%; width: 230px; transform: rotate(7deg); z-index: 2; animation-delay: 2.2s; }
.card-c .top { display: flex; justify-content: space-between; align-items: flex-start; }
@keyframes bob {
0%, 100% { margin-top: 0; }
50% { margin-top: -12px; }
}
/* ---------- Bottom copy ---------- */
.hero-sub {
margin-top: clamp(30px, 6vh, 60px);
max-width: 40ch;
font-size: clamp(1rem, 1.6vw, 1.25rem);
line-height: 1.6;
color: var(--ink-soft);
text-shadow: 0 1px 12px rgba(9, 40, 90, 0.25);
opacity: 0;
animation: fade-up 0.9s ease 1.25s forwards;
}
.cta {
display: inline-flex;
align-items: center;
gap: 9px;
margin-top: 22px;
background: #ffffff;
color: #0e1526;
font-size: 15px;
font-weight: 700;
padding: 15px 28px;
border-radius: 999px;
text-decoration: none;
box-shadow: 0 14px 40px rgba(8, 35, 80, 0.3);
transition: transform 0.25s ease, box-shadow 0.25s ease;
opacity: 0;
animation: fade-up 0.9s ease 1.4s forwards;
}
.cta:hover { transform: translateY(-3px); box-shadow: 0 20px 50px rgba(8, 35, 80, 0.4); }
/* Theme toggle */
.theme-toggle {
position: fixed;
top: 20px;
right: 20px;
z-index: 11;
width: 46px;
height: 46px;
border-radius: 999px;
border: 1px solid var(--glass-border);
background: var(--glass);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
display: grid;
place-items: center;
cursor: pointer;
color: var(--ink);
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
.theme-toggle:hover { transform: scale(1.08); }
.theme-toggle svg { width: 21px; height: 21px; position: absolute; transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s ease; }
html[data-theme="light"] .icon-moon { transform: rotate(-90deg) scale(0.4); opacity: 0; }
html[data-theme="dark"] .icon-sun { transform: rotate(90deg) scale(0.4); opacity: 0; }
@keyframes rise { to { transform: translateY(0); } }
@keyframes fade-up { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } }
@keyframes fade-down { from { opacity: 0; transform: translate(-50%, -14px); } to { opacity: 1; transform: translate(-50%, 0); } }
@media (max-width: 640px) {
.cards { height: auto; display: flex; flex-direction: column; gap: 14px; align-items: center; }
.card { position: static; transform: none !important; width: min(300px, 88vw) !important; }
.card-b { transform: none !important; }
}
@media (prefers-reduced-motion: reduce) {
.word { animation: none; transform: none; }
.nav, .cards, .hero-sub, .cta { animation: none; opacity: 1; }
.card { animation: none; }
* { transition-duration: 0.01ms !important; }
}
</style>
</head>
<body>
<canvas id="sky-canvas" aria-hidden="true"></canvas>
<div class="sky-fallback" aria-hidden="true"></div>
<div class="sky-edge-blur top" aria-hidden="true"></div>
<div class="sky-edge-blur bottom" aria-hidden="true"></div>
<nav class="nav">
<a class="nav-logo" href="#"><span>✈</span> Folio</a>
<a class="link" href="#work">Work</a>
<a class="link" href="#about">About</a>
<a class="link" href="#writing">Writing</a>
<a class="link" href="#contact">Contact</a>
<a class="login" href="#contact">Hire me</a>
</nav>
<main class="hero">
<h1 aria-label="Building immersive web experiences.">
<span class="line" aria-hidden="true"><span class="word" style="--d: 0.2s">Building</span></span>
<span class="line" aria-hidden="true"><span class="word" style="--d: 0.32s">immersive</span></span>
<span class="line" aria-hidden="true"><span class="word" style="--d: 0.44s">web experiences.</span></span>
</h1>
<div class="cards" aria-hidden="false">
<div class="card card-a">
<h3>Design, code & motion — crafted in one studio.</h3>
<a class="pill" href="#work"><span>+</span> View my work</a>
</div>
<div class="card card-b">
<div class="row" style="margin-top:0; align-items:center;">
<span style="color:#fff; font-weight:600; font-size:13.5px;">✦ Aurora</span>
<span>•••</span>
</div>
<div class="big">WebGL</div>
<p style="text-align:right; margin-top:-6px;">Immersive 3D experience</p>
<div class="row">
<span>2026 · Case study</span>
<button class="orb" aria-label="Open project">↗</button>
</div>
</div>
<div class="card card-c">
<div class="top">
<div>
<div class="label">Status</div>
<h3 style="margin-top:6px;">Open for freelance</h3>
</div>
<button class="orb" aria-label="Book a call">↗</button>
</div>
<a class="pill" href="#contact">Book a call</a>
</div>
</div>
<p class="hero-sub">
Everything I build — projects, experiments and writing,
gathered in one place under a sky that changes with your theme.
</p>
<a class="cta" href="#work"> See my work</a>
</main>
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle dark mode" aria-pressed="false">
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>
</svg>
<svg class="icon-moon" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z"/>
</svg>
</button>
<script>
(() => {
"use strict";
const root = document.documentElement;
const canvas = document.getElementById("sky-canvas");
const toggleBtn = document.getElementById("theme-toggle");
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
const systemDark = window.matchMedia("(prefers-color-scheme: dark)");
/* ---------------- Theme state ---------------- */
const state = { day: 1, from: 1, to: 1, t0: 0, animating: false, duration: reducedMotion.matches ? 0 : 1900 };
const clamp01 = (v) => Math.min(1, Math.max(0, v));
const easeInOutCubic = (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2);
function applyTheme(dark, { animate = true, persist = true } = {}) {
root.dataset.theme = dark ? "dark" : "light";
toggleBtn.setAttribute("aria-pressed", String(dark));
if (persist) { try { localStorage.setItem("sky-theme", dark ? "dark" : "light"); } catch {} }
const target = dark ? 0 : 1;
if (animate && state.duration > 0) {
state.from = state.day; state.to = target; state.t0 = performance.now(); state.animating = true;
} else { state.day = target; state.animating = false; }
}
let saved = null;
try { saved = localStorage.getItem("sky-theme"); } catch {}
applyTheme(saved ? saved === "dark" : systemDark.matches, { animate: false, persist: false });
toggleBtn.addEventListener("click", () => applyTheme(root.dataset.theme !== "dark"));
systemDark.addEventListener("change", (e) => {
let manual = false;
try { manual = !!localStorage.getItem("sky-theme"); } catch {}
if (!manual) applyTheme(e.matches);
});
/* ---------------- WebGL sky ---------------- */
const VERT = `
attribute vec2 aPos;
varying vec2 vUv;
void main() {
vUv = aPos * 0.5 + 0.5;
gl_Position = vec4(aPos, 0.0, 1.0);
}
`;
const FRAG = `
precision highp float;
varying vec2 vUv;
uniform float uTime;
uniform float uDay; // 0 night -> 1 day
uniform vec2 uRes;
float hash(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i + vec2(1,0)), u.x),
mix(hash(i + vec2(0,1)), hash(i + vec2(1,1)), u.x), u.y);
}
float fbm(vec2 p) {
float v = 0.0, amp = 0.5;
mat2 rot = mat2(0.8, 0.6, -0.6, 0.8);
for (int i = 0; i < 4; i++) { v += amp * noise(p); p = rot * p * 2.03; amp *= 0.5; }
return v;
}
// Billowing cumulus field in cloud-local space (q, y-up).
// Radial envelope + fbm billows + flat-bottom carve = fluffy top, flat base.
float cloudMap(vec2 q, float seedT) {
float f = fbm(q * 1.35 + vec2(seedT, seedT * 0.13));
float env = 1.0 - smoothstep(0.30, 1.30, length(q * vec2(0.60, 1.05)));
float d = f * 1.35 * env;
float carve = 1.0 - smoothstep(-0.45, -0.05, q.y); // carve flat bottoms
d -= carve * 0.75;
return d;
}
// One cumulus cluster centered at c with world radius s. Returns alpha,
// writes lit shading factor (top-lit vs base shade).
float cloud(vec2 p, vec2 c, float s, float seed, out float lit) {
vec2 q = (p - c) / s;
float env = 1.0 - smoothstep(0.30, 1.30, length(q * vec2(0.60, 1.05)));
lit = 0.0;
if (env < 0.004) return 0.0;
float d = cloudMap(q, seed + uTime * 0.05);
float a = smoothstep(0.30, 0.55, d);
float dUp = cloudMap(q + vec2(0.0, 0.30), seed + uTime * 0.05);
lit = clamp(0.55 + (d - dUp) * 2.4, 0.0, 1.0);
return a;
}
void main() {
float aspect = uRes.x / uRes.y;
vec2 p = vec2((vUv.x - 0.5) * aspect, vUv.y - 0.5);
float h = clamp(vUv.y, 0.0, 1.0);
float day = uDay;
float t = uTime;
// Sunset factor peaks mid-transition
float s = smoothstep(0.05, 0.5, day) * smoothstep(1.0, 0.55, day);
// --- Vivid sky gradient (reference-style azure) ---
vec3 nightTop = vec3(0.012, 0.020, 0.060);
vec3 nightHor = vec3(0.050, 0.080, 0.170);
vec3 dayTop = vec3(0.075, 0.400, 0.870);
vec3 dayHor = vec3(0.480, 0.720, 0.970);
vec3 duskTop = vec3(0.14, 0.10, 0.28);
vec3 duskHor = vec3(0.99, 0.44, 0.20);
vec3 top = mix(nightTop, dayTop, day);
vec3 hor = mix(nightHor, dayHor, day);
top = mix(top, duskTop, s * 0.9);
hor = mix(hor, duskHor, s);
vec3 sky = mix(hor, top, pow(h, 0.80));
// --- Sun: soft glow + small disc, mostly behind the big cloud (day) ---
vec2 sunPos = vec2(0.44 * aspect, 0.36);
float sd = length(p - sunPos);
float sunDisc = smoothstep(0.052, 0.040, sd);
float sunGlow = pow(max(0.0, 1.0 - sd * 1.25), 3.0);
sky += (sunDisc * vec3(1.0, 0.98, 0.88) * 0.9 + sunGlow * vec3(1.0, 0.88, 0.6) * 0.30) * day;
// --- Moon: crescent + glow (night) ---
vec2 moonPos = vec2(-0.30 * aspect, 0.32);
float md = length(p - moonPos);
float moonDisc = smoothstep(0.060, 0.050, md);
float carve = smoothstep(0.050, 0.038, length(p - (moonPos + vec2(0.025, 0.013))));
float moonShape = clamp(moonDisc - carve * 0.92, 0.0, 1.0);
float moonGlow = pow(max(0.0, 1.0 - md * 2.1), 3.0) * 0.35;
sky += (moonShape * vec3(0.93, 0.96, 1.0) + moonGlow * vec3(0.60, 0.72, 1.0)) * (1.0 - day);
// --- Stars (night, above horizon) ---
vec2 cell = floor(p * 95.0);
vec2 f = fract(p * 95.0);
float rnd = hash(cell);
float star = smoothstep(0.965, 1.0, rnd);
vec2 starPos = vec2(hash(cell + 7.3), hash(cell + 13.7)) * 0.8 + 0.1;
float twinkle = 0.55 + 0.45 * sin(uTime * 2.2 + rnd * 47.0);
star *= smoothstep(0.13, 0.0, length(f - starPos)) * twinkle;
sky += star * (1.0 - day) * smoothstep(0.04, 0.45, h) * vec3(0.85, 0.92, 1.0);
// --- Cloud colors: sunlit white / gray-blue bases (day), moonlit navy (night) ---
vec3 litDay = vec3(1.00, 1.00, 1.00);
vec3 shadeDay = vec3(0.58, 0.70, 0.86);
vec3 litNight = vec3(0.34, 0.40, 0.56);
vec3 shadeNight = vec3(0.050, 0.070, 0.140);
vec3 cLit = mix(litNight, litDay, day);
vec3 cShade = mix(shadeNight, shadeDay, day);
cLit = mix(cLit, vec3(1.00, 0.62, 0.38), s * 0.85); // sunset rim
cShade = mix(cShade, vec3(0.42, 0.22, 0.30), s * 0.55);
// --- Distant haze clouds near the horizon (atmospheric perspective) ---
{
float band = exp(-pow((p.y + 0.16) * 5.5, 2.0));
vec2 dq = vec2(p.x * 3.2 + t * 0.010, p.y * 9.0);
float dd = fbm(dq) * band * 1.35;
float da = smoothstep(0.40, 0.62, dd);
float dUp2 = fbm(dq + vec2(0.0, 0.55)) * band * 1.35;
float lit2 = clamp(0.55 + (dd - dUp2) * 2.2, 0.0, 1.0);
vec3 dcol = mix(cShade, cLit, lit2);
dcol = mix(dcol, hor, 0.35); // fade into sky (distance haze)
sky = mix(sky, dcol, da * 0.75);
}
// --- Cumulus clusters, far -> near (parallax drift + wrap) ---
float cw = 1.35 * aspect;
#define WRAPX(x, spd) (mod((x) + t * (spd) + cw, 2.0 * cw) - cw)
// Big clusters hug the corners (like the reference); center stays clear.
vec2 c6pos = vec2(WRAPX(-0.80 * aspect, 0.010), 0.06);
vec2 c5pos = vec2(WRAPX( 0.55 * aspect, 0.007), 0.12);
vec2 c4pos = vec2(WRAPX(-0.55 * aspect, 0.012), -0.33);
vec2 c3pos = vec2(WRAPX( 0.74 * aspect, 0.014), 0.32);
vec2 c2pos = vec2(WRAPX( 0.16 * aspect, 0.009), -0.38);
vec2 c1pos = vec2(WRAPX(-0.52 * aspect, 0.006), 0.42);
float lit; float a;
a = cloud(p, c6pos, 0.20, 31.7, lit); sky = mix(sky, mix(cShade, cLit, lit), a * 0.9);
a = cloud(p, c5pos, 0.13, 52.1, lit); sky = mix(sky, mix(cShade, cLit, lit), a * 0.8);
a = cloud(p, c4pos, 0.24, 27.9, lit); sky = mix(sky, mix(cShade, cLit, lit), a * 0.95);
a = cloud(p, c3pos, 0.42, 4.7, lit); sky = mix(sky, mix(cShade, cLit, lit), a * 0.97);
a = cloud(p, c2pos, 0.16, 63.3, lit); sky = mix(sky, mix(cShade, cLit, lit), a);
a = cloud(p, c1pos, 0.55, 7.7, lit); sky = mix(sky, mix(cShade, cLit, lit), a);
// --- Vignette + dither ---
vec2 q2 = vUv - 0.5;
sky *= 1.0 - dot(q2, q2) * 0.18;
sky += (hash(vUv * uRes + fract(uTime)) - 0.5) / 160.0;
gl_FragColor = vec4(sky, 1.0);
}
`;
let gl = null;
let uniforms = { uTime: null, uDay: null, uRes: null };
const GL_OPTS = { antialias: false, alpha: false, powerPreference: "high-performance" };
function initGL() {
gl = canvas.getContext("webgl", GL_OPTS) || canvas.getContext("experimental-webgl", GL_OPTS);
if (!gl) return false;
const compile = (type, src) => {
const sh = gl.createShader(type);
gl.shaderSource(sh, src);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { console.error("Shader error:", gl.getShaderInfoLog(sh)); return null; }
return sh;
};
const vs = compile(gl.VERTEX_SHADER, VERT);
const fs = compile(gl.FRAGMENT_SHADER, FRAG);
if (!vs || !fs) return false;
const prog = gl.createProgram();
gl.attachShader(prog, vs); gl.attachShader(prog, fs);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { console.error("Link error:", gl.getProgramInfoLog(prog)); return false; }
gl.useProgram(prog);
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(prog, "aPos");
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
uniforms.uTime = gl.getUniformLocation(prog, "uTime");
uniforms.uDay = gl.getUniformLocation(prog, "uDay");
uniforms.uRes = gl.getUniformLocation(prog, "uRes");
return true;
}
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 1.75);
const w = Math.floor(window.innerWidth * dpr);
const hgt = Math.floor(window.innerHeight * dpr);
if (canvas.width !== w || canvas.height !== hgt) {
canvas.width = w; canvas.height = hgt;
if (gl) gl.viewport(0, 0, w, hgt);
}
}
const hasWebGL = (() => { try { return initGL(); } catch (e) { console.error(e); return false; } })();
function drawOne() {
if (!gl) return;
gl.uniform1f(uniforms.uTime, 12.0);
gl.uniform1f(uniforms.uDay, state.day);
gl.uniform2f(uniforms.uRes, canvas.width, canvas.height);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
if (hasWebGL) {
// Size first (resizing clears the buffer), then draw one frame immediately
// so the sky is visible before the first rAF.
resize();
drawOne();
} else {
// Context creation can transiently fail (e.g. GPU/webview not ready yet).
// Retry a few times before committing to the static fallback.
document.documentElement.classList.add("no-webgl");
let attempts = 0;
const retry = setInterval(() => {
attempts++;
let ok = false;
try { ok = initGL(); } catch {}
if (ok) {
clearInterval(retry);
document.documentElement.classList.remove("no-webgl");
resize();
drawOne();
} else if (attempts >= 12) {
clearInterval(retry);
canvas.remove();
console.warn("WebGL unavailable — using static sky fallback");
}
}, 400);
}
canvas.addEventListener("webglcontextlost", (e) => e.preventDefault());
canvas.addEventListener("webglcontextrestored", () => { if (initGL()) { resize(); drawOne(); } });
window.addEventListener("resize", resize);
resize();
// Debug hook: redraw synchronously so tests/probes can readPixels in the same task.
window.__skyDraw = () => {
if (!gl) return false;
gl.uniform1f(uniforms.uTime, reducedMotion.matches ? 12.0 : performance.now() / 1000);
gl.uniform1f(uniforms.uDay, state.day);
gl.uniform2f(uniforms.uRes, canvas.width, canvas.height);
gl.drawArrays(gl.TRIANGLES, 0, 3);
return true;
};
let running = true;
document.addEventListener("visibilitychange", () => { running = !document.hidden; });
function frame(now) {
requestAnimationFrame(frame);
if (!running) return;
if (state.animating) {
const k = state.duration > 0 ? clamp01((now - state.t0) / state.duration) : 1;
state.day = state.from + (state.to - state.from) * easeInOutCubic(k);
if (k >= 1) state.animating = false;
}
if (gl) {
gl.uniform1f(uniforms.uTime, reducedMotion.matches ? 12.0 : now / 1000);
gl.uniform1f(uniforms.uDay, state.day);
gl.uniform2f(uniforms.uRes, canvas.width, canvas.height);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
}
requestAnimationFrame(frame);
})();
</script>
</body>
</html>