diff --git a/package-lock.json b/package-lock.json index d68c014..0d9e2f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "dangrubb-astro", "version": "0.0.1", "dependencies": { + "@fontsource/geist-mono": "^5.3.0", + "@fontsource/instrument-sans": "^5.3.0", "astro": "^6.1.9" }, "engines": { @@ -593,6 +595,24 @@ "node": ">=18" } }, + "node_modules/@fontsource/geist-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/geist-mono/-/geist-mono-5.3.0.tgz", + "integrity": "sha512-UtJ1BBBCVpMYdIcW7nEB45UAoAw5M53ZXs2t0ciPW+IokuAAIc56M8+kW5tXbRJCTpDw4XTtU7proT6NdQAHTg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/instrument-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/instrument-sans/-/instrument-sans-5.3.0.tgz", + "integrity": "sha512-QwXc4hb/px3XvSPS2CPAOgey8nyrQFxgxrmXQQ+pN+P51hKdAcchxpg8rSbjANFfPu6VKXhDqkVDucXMZ9CM5g==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", diff --git a/package.json b/package.json index 91a7941..a429400 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "astro": "astro" }, "dependencies": { + "@fontsource/geist-mono": "^5.3.0", + "@fontsource/instrument-sans": "^5.3.0", "astro": "^6.1.9" } -} \ No newline at end of file +} diff --git a/public/src/img/DanGrubbLogoDark.png b/public/src/img/DanGrubbLogoDark.png new file mode 100644 index 0000000..a540524 Binary files /dev/null and b/public/src/img/DanGrubbLogoDark.png differ diff --git a/src/pages/index.astro b/src/pages/index.astro index 3bf406a..03bfa68 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,182 +1,97 @@ --- -import CrushBase from '../layouts/CrushBase.astro'; +import '@fontsource/instrument-sans/latin-400.css'; +import '@fontsource/instrument-sans/latin-500.css'; +import '@fontsource/geist-mono/latin-500.css'; +import '../styles/radiance.css'; + +const nav = [ + { label: 'Intelligence Platform', href: 'https://ai.dangrubb.net' }, + { label: 'Exclusive Music', href: '/music' }, + { label: 'Multimedia Entertainment', href: 'https://pi.dangrubb.net/jellyfin' }, + { label: 'Crypto', href: '#' }, + { label: 'Storage Solutions', href: 'https://pi.dangrubb.net/nextcloud' }, +]; +const links = [ + { label: 'Blog', href: '/blog' }, + { label: 'CV', href: '/cv' }, + { label: 'Git', href: 'https://pi.dangrubb.net/dangit' }, +]; --- - -
-
-
- dangrubb.net + + + + + + dangrubb.net + + + + + + + + +
+ + +
-
- -
- DanGrubb -
+
+ +

Welcome to dangrubb.net

- -
- + + + diff --git a/src/scripts/orb.js b/src/scripts/orb.js new file mode 100644 index 0000000..a3ca514 --- /dev/null +++ b/src/scripts/orb.js @@ -0,0 +1,328 @@ +// Radiance orb: a glassy sphere of particles with a sparkling corona. +// Raw WebGL (no libraries) so the home page stays tiny and loads fast. + +const VERT_PARTICLES = ` +precision highp float; +attribute vec3 aDir; +attribute vec4 aRand; +uniform vec2 uRes; +uniform vec2 uCenter; +uniform float uR; +uniform float uTime; +uniform float uDpr; +uniform vec2 uTilt; +uniform float uMode; // 0 = inner particles, 1 = corona +varying vec4 vColor; +varying float vGlow; + +float hash(vec3 p) { + p = fract(p * 0.3183099 + 0.1); + p *= 17.0; + return fract(p.x * p.y * p.z * (p.x + p.y + p.z)); +} +float vnoise(vec3 x) { + vec3 i = floor(x); + vec3 f = fract(x); + f = f * f * (3.0 - 2.0 * f); + return mix( + mix(mix(hash(i), hash(i + vec3(1, 0, 0)), f.x), + mix(hash(i + vec3(0, 1, 0)), hash(i + vec3(1, 1, 0)), f.x), f.y), + mix(mix(hash(i + vec3(0, 0, 1)), hash(i + vec3(1, 0, 1)), f.x), + mix(hash(i + vec3(0, 1, 1)), hash(i + vec3(1, 1, 1)), f.x), f.y), + f.z); +} +float fbm(vec3 p) { + float v = 0.0; + float a = 0.55; + for (int i = 0; i < 3; i++) { + v += a * vnoise(p); + p = p * 2.03 + 11.7; + a *= 0.5; + } + return v; +} +vec3 rotY(vec3 p, float a) { + float c = cos(a), s = sin(a); + return vec3(c * p.x + s * p.z, p.y, -s * p.x + c * p.z); +} +vec3 rotX(vec3 p, float a) { + float c = cos(a), s = sin(a); + return vec3(p.x, c * p.y - s * p.z, s * p.y + c * p.z); +} + +void main() { + float t = uTime; + vec3 d = aDir; + float size = 1.0; + vec4 col = vec4(0.0); + float sc = clamp(uR / 315.0, 0.8, 1.7); + + if (uMode < 0.5) { + // ---- inner particles: density follows a slowly drifting, domain-warped noise field + float rad = 0.955 + aRand.x * 0.04; + vec3 q = d * 1.45 + vec3(0.0, t * 0.03, t * 0.02); + float n = fbm(q + 1.6 * fbm(q * 0.8 + 3.1)); + n = clamp((n - 0.46) * 2.3 + 0.5, 0.0, 1.0); + float keep = smoothstep(0.05, 0.7, n) - aRand.y * 0.7; + float spark = step(0.94, aRand.z); + if (keep < 0.0 && spark < 0.5) { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); gl_PointSize = 0.0; vColor = vec4(0.0); vGlow = 0.0; return; } + vec3 warm = mix(vec3(0.90, 0.22, 0.17), vec3(0.99, 0.50, 0.30), aRand.w); + warm = mix(warm, vec3(1.0, 0.72, 0.42), smoothstep(0.1, 0.45, 1.0 - n) * 0.8); + col = vec4(mix(warm, vec3(1.0, 0.97, 0.84), spark), mix(0.85, 0.95, spark)); + size = mix(1.6 + aRand.y * 1.6 + n * 1.0, 3.2 + aRand.w * 2.6, spark); + float tw = 0.5 + 0.5 * sin(t * (1.2 + aRand.x * 2.5) + aRand.w * 40.0); + col.a *= mix(1.0, tw, spark); + vGlow = spark; + vec3 p = rotX(rotY(d, t * 0.045 + 2.4 + uTilt.x), uTilt.y) * rad; + float persp = 1.0 / (1.0 - p.z * 0.12); + // far side of the sphere reads dimmer through the glass + col.a *= mix(0.25, 1.0, smoothstep(-0.4, 0.4, p.z)); + vec2 px = uCenter + vec2(p.x, -p.y) * uR * persp; + gl_Position = vec4(px.x / uRes.x * 2.0 - 1.0, 1.0 - px.y / uRes.y * 2.0, 0.0, 1.0); + gl_PointSize = size * sc * uDpr * persp; + vColor = col; + } else { + // ---- corona: sparks thrown off the limb, laid out in screen space; strongest over the top + vec2 cd = d.xy; + float n = fbm(vec3(cd * 4.2, t * 0.10)); + float up = smoothstep(-0.75, 0.9, cd.y); + float spike = pow(n, 1.6) * 1.6; + float reach = pow(aRand.x, 1.5) * (0.05 + 0.4 * spike) * (0.3 + 0.7 * up); + reach *= 0.88 + 0.12 * sin(t * 0.7 + aRand.w * 20.0); + reach += 0.012 * sin(t * 0.9 + aRand.z * 30.0); + float fade = 1.0 - smoothstep(0.0, 0.42, reach); + col = vec4(1.0, 0.97, 0.86, (0.3 + 0.7 * fade) * (0.35 + 0.65 * smoothstep(0.2, 0.75, n))); + float tw = 0.5 + 0.5 * sin(t * (1.0 + aRand.y * 3.0) + aRand.z * 60.0); + col.a *= mix(0.5, 1.0, tw); + size = 1.0 + aRand.y * aRand.y * 2.6 * (0.3 + fade); + vGlow = 0.5; + vec2 px = uCenter + vec2(cd.x, -cd.y) * (1.0 + 0.004 + reach) * uR; + gl_Position = vec4(px.x / uRes.x * 2.0 - 1.0, 1.0 - px.y / uRes.y * 2.0, 0.0, 1.0); + gl_PointSize = size * sc * uDpr; + vColor = col; + } +} +`; + +const FRAG_PARTICLES = ` +precision mediump float; +varying vec4 vColor; +varying float vGlow; +void main() { + vec2 c = gl_PointCoord - 0.5; + float r = length(c); + float core = smoothstep(0.5, 0.18, r); + float halo = vGlow * smoothstep(0.5, 0.0, r) * 0.35; + float a = vColor.a * clamp(core + halo, 0.0, 1.0); + if (a < 0.01) discard; + gl_FragColor = vec4(vColor.rgb * a, a); +} +`; + +const VERT_QUAD = ` +attribute vec2 aPos; +varying vec2 vUv; +void main() { + vUv = aPos; + gl_Position = vec4(aPos, 0.0, 1.0); +} +`; + +const FRAG_SHELL = ` +precision highp float; +varying vec2 vUv; +uniform vec2 uRes; +uniform vec2 uCenter; +uniform float uR; +uniform float uTime; + +void main() { + vec2 frag = vec2(gl_FragCoord.x, uRes.y - gl_FragCoord.y); + vec2 d = (frag - uCenter) / uR; + float r = length(d); + + // soft light spilling around the silhouette + float glow = 0.28 * exp(-(r - 1.0) * 7.5) * step(1.0, r); + + float edge = 1.0 - smoothstep(0.994, 1.004, r); + float z = sqrt(max(0.0, 1.0 - r * r)); + vec3 n = vec3(d.x, -d.y, z); + + // glassy body: lighter top-left, deeper orange bottom-right + vec3 top = vec3(0.992, 0.76, 0.52); + vec3 bot = vec3(0.975, 0.55, 0.38); + float lit = clamp(0.5 + 0.5 * dot(normalize(n), normalize(vec3(-0.45, 0.6, 0.65))), 0.0, 1.0); + vec3 body = mix(bot, top, lit); + + // fresnel rim, pale pink-white + float fres = pow(1.0 - z, 2.0); + vec3 rim = vec3(1.0, 0.89, 0.86); + vec3 col = mix(body, rim, clamp(fres * 1.15, 0.0, 1.0)); + float a = mix(0.7, 0.97, fres); + + // crisp bright edge line + float ring = smoothstep(0.955, 0.985, r) * (1.0 - smoothstep(0.985, 1.004, r)); + col = mix(col, vec3(1.0, 0.95, 0.93), ring * 0.6); + + vec4 shell = vec4(col * a, a) * edge; + vec4 halo = vec4(vec3(1.0, 0.96, 0.86) * glow, glow); + gl_FragColor = shell + halo * (1.0 - shell.a); +} +`; + +function compile(gl, type, src) { + const s = gl.createShader(type); + gl.shaderSource(s, src); + gl.compileShader(s); + if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s)); + return s; +} +function program(gl, vs, fs) { + const p = gl.createProgram(); + gl.attachShader(p, compile(gl, gl.VERTEX_SHADER, vs)); + gl.attachShader(p, compile(gl, gl.FRAGMENT_SHADER, fs)); + gl.linkProgram(p); + if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p)); + return p; +} +function rng(seed) { + return () => { + seed |= 0; seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} +function cloud(count, seed, ring) { + const rand = rng(seed); + const dir = new Float32Array(count * 3); + const rnd = new Float32Array(count * 4); + for (let i = 0; i < count; i++) { + if (ring) { + const a = rand() * Math.PI * 2; + dir.set([Math.cos(a), Math.sin(a), 0], i * 3); + } else { + const y = rand() * 2 - 1; + const phi = rand() * Math.PI * 2; + const s = Math.sqrt(1 - y * y); + dir.set([Math.cos(phi) * s, y, Math.sin(phi) * s], i * 3); + } + rnd.set([rand(), rand(), rand(), rand()], i * 4); + } + return { dir, rnd, count }; +} + +export function startOrb(canvas, { onReady } = {}) { + const gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: true, antialias: false, powerPreference: 'low-power' }); + if (!gl) return false; + + const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; + const small = Math.min(innerWidth, innerHeight) < 600; + const inner = cloud(small ? 28000 : 52000, 7); + const corona = cloud(small ? 6000 : 13000, 21, true); + + const pp = program(gl, VERT_PARTICLES, FRAG_PARTICLES); + const ps = program(gl, VERT_QUAD, FRAG_SHELL); + + const buf = (data) => { + const b = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, b); + gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW); + return b; + }; + const bufs = { + iDir: buf(inner.dir), iRnd: buf(inner.rnd), + cDir: buf(corona.dir), cRnd: buf(corona.rnd), + quad: buf(new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1])), + }; + + const U = (p, names) => Object.fromEntries(names.map((n) => [n, gl.getUniformLocation(p, n)])); + const up = U(pp, ['uRes', 'uCenter', 'uR', 'uTime', 'uDpr', 'uTilt', 'uMode']); + const us = U(ps, ['uRes', 'uCenter', 'uR', 'uTime']); + const aDir = gl.getAttribLocation(pp, 'aDir'); + const aRand = gl.getAttribLocation(pp, 'aRand'); + const aPos = gl.getAttribLocation(ps, 'aPos'); + + gl.enable(gl.BLEND); + gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); + gl.clearColor(0, 0, 0, 0); + + let W = 0, H = 0, dpr = 1, cx = 0, cy = 0, R = 0; + function resize() { + dpr = Math.min(window.devicePixelRatio || 1, 2); + W = canvas.clientWidth; H = canvas.clientHeight; + canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr); + gl.viewport(0, 0, canvas.width, canvas.height); + const portrait = W < H * 0.85; + R = portrait ? Math.min(W * 0.46, H * 0.3) : W * 0.2625; + cx = W / 2; + cy = H * (portrait ? 0.8 : 0.52) + R * (portrait ? 0.15 : 1); + } + resize(); + addEventListener('resize', resize); + + // gentle pointer parallax + const target = { x: 0, y: 0 }, cur = { x: 0, y: 0 }; + addEventListener('pointermove', (e) => { + target.x = (e.clientX / innerWidth - 0.5) * 2; + target.y = (e.clientY / innerHeight - 0.5) * 2; + }, { passive: true }); + + function bindParticles(dirBuf, rndBuf) { + gl.bindBuffer(gl.ARRAY_BUFFER, dirBuf); + gl.enableVertexAttribArray(aDir); + gl.vertexAttribPointer(aDir, 3, gl.FLOAT, false, 0, 0); + gl.bindBuffer(gl.ARRAY_BUFFER, rndBuf); + gl.enableVertexAttribArray(aRand); + gl.vertexAttribPointer(aRand, 4, gl.FLOAT, false, 0, 0); + } + + const t0 = performance.now(); + let ready = false; + function frame(now) { + const t = reduced ? 8 : (now - t0) / 1000; + cur.x += (target.x - cur.x) * 0.04; + cur.y += (target.y - cur.y) * 0.04; + const ox = cur.x * 10, oy = cur.y * 6; + + gl.clear(gl.COLOR_BUFFER_BIT); + + // 1. corona (behind the shell) + gl.useProgram(pp); + gl.uniform2f(up.uRes, W, H); + gl.uniform2f(up.uCenter, cx + ox, cy + oy); + gl.uniform1f(up.uR, R); + gl.uniform1f(up.uTime, t); + gl.uniform1f(up.uDpr, dpr); + gl.uniform2f(up.uTilt, cur.x * 0.25, cur.y * 0.12); + gl.disableVertexAttribArray(aPos); + gl.uniform1f(up.uMode, 1); + bindParticles(bufs.cDir, bufs.cRnd); + gl.drawArrays(gl.POINTS, 0, corona.count); + + // 2. glass shell + glow + gl.useProgram(ps); + gl.uniform2f(us.uRes, canvas.width, canvas.height); + gl.uniform2f(us.uCenter, (cx + ox) * dpr, (cy + oy) * dpr); + gl.uniform1f(us.uR, R * dpr); + gl.uniform1f(us.uTime, t); + gl.disableVertexAttribArray(aDir); + gl.disableVertexAttribArray(aRand); + gl.bindBuffer(gl.ARRAY_BUFFER, bufs.quad); + gl.enableVertexAttribArray(aPos); + gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); + gl.disableVertexAttribArray(aPos); + + // 3. particles inside / on the shell + gl.useProgram(pp); + gl.uniform1f(up.uMode, 0); + bindParticles(bufs.iDir, bufs.iRnd); + gl.drawArrays(gl.POINTS, 0, inner.count); + + if (!ready) { ready = true; onReady && onReady(); } + if (!reduced) requestAnimationFrame(frame); + } + requestAnimationFrame(frame); + return true; +} diff --git a/src/scripts/ui.js b/src/scripts/ui.js new file mode 100644 index 0000000..5f9c7c2 --- /dev/null +++ b/src/scripts/ui.js @@ -0,0 +1,80 @@ +// Loader, title reveal and menu behaviour for the home page. + +export function initUI(startOrb) { + const body = document.body; + const loader = document.getElementById('loader'); + const count = document.getElementById('loader-count'); + const title = document.getElementById('title'); + const canvas = document.getElementById('orb'); + const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; + + // --- title: split into letters (each wrapped per word so lines break cleanly) + const text = title.textContent.trim(); + title.setAttribute('aria-label', text); + title.textContent = ''; + let n = 0; + text.split(' ').forEach((word, wi, words) => { + const w = document.createElement('span'); + w.className = 'word'; + w.setAttribute('aria-hidden', 'true'); + for (const ch of word) { + const mask = document.createElement('span'); + mask.className = 'ch'; + const inner = document.createElement('span'); + inner.textContent = ch; + inner.style.transitionDelay = `${0.04 * n++}s`; + mask.appendChild(inner); + w.appendChild(mask); + } + title.appendChild(w); + if (wi < words.length - 1) title.appendChild(document.createTextNode(' ')); + }); + + // --- loader: counts up while fonts and the first orb frame get ready + let orbReady = false; + let fontsReady = false; + let shown = 0; + const started = performance.now(); + const minimum = reduced ? 0 : 1100; + + const webgl = startOrb(canvas, { onReady: () => { orbReady = true; } }); + if (!webgl) { document.documentElement.classList.add('no-webgl'); orbReady = true; } + (document.fonts ? document.fonts.ready : Promise.resolve()).then(() => { fontsReady = true; }); + + function tick(now) { + const elapsed = now - started; + const real = (orbReady ? 0.5 : 0.1) + (fontsReady ? 0.5 : 0.15); + const timed = Math.min(1, elapsed / (minimum || 1)); + const target = Math.min(real, timed) * 100; + shown += (target - shown) * 0.2; + if (target - shown < 0.5) shown = target; + count.textContent = String(Math.round(shown)).padStart(3, '0') + '%'; + if (shown >= 99.5 && orbReady && fontsReady && elapsed >= minimum) return done(); + requestAnimationFrame(tick); + } + requestAnimationFrame(tick); + + function done() { + count.textContent = '100%'; + body.classList.remove('is-loading'); + body.classList.add('is-ready'); + setTimeout(() => loader.remove(), 900); + } + + // --- menu + const menu = document.getElementById('menu'); + const btn = document.getElementById('menu-btn'); + const panel = document.getElementById('menu-panel'); + + function setOpen(open) { + menu.classList.toggle('is-open', open); + btn.setAttribute('aria-expanded', String(open)); + panel.toggleAttribute('inert', !open); + } + setOpen(false); + btn.addEventListener('click', () => setOpen(!menu.classList.contains('is-open'))); + addEventListener('keydown', (e) => { if (e.key === 'Escape') { setOpen(false); btn.focus(); } }); + document.addEventListener('pointerdown', (e) => { + if (menu.classList.contains('is-open') && !menu.contains(e.target)) setOpen(false); + }); +} diff --git a/src/styles/radiance.css b/src/styles/radiance.css new file mode 100644 index 0000000..3b56ae1 --- /dev/null +++ b/src/styles/radiance.css @@ -0,0 +1,195 @@ +/* Radiance home page. Sizes are written in --u units (1u = 1px on a 1600px-wide design). */ + +:root { + --ink: 9 11 17; + --u: clamp(0.5px, 0.0625vw, 1.25px); + --ease: cubic-bezier(0.33, 0, 0.2, 1); +} + +*, *::before, *::after { box-sizing: border-box; } +html, body { margin: 0; height: 100%; } +body { + overflow: hidden; + background: #fff6f4; + color: rgb(var(--ink) / 0.8); + font-family: 'Instrument Sans', system-ui, sans-serif; + -webkit-font-smoothing: antialiased; +} +a { color: inherit; text-decoration: none; } +ul { list-style: none; margin: 0; padding: 0; } +button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; } +img { display: block; } +.sr-only { + position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; + overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; +} +:focus-visible { outline: 2px solid rgb(var(--ink) / 0.7); outline-offset: 2px; } + +/* ---------- hero ---------- */ +.hero { + position: fixed; inset: 0; + background: + radial-gradient(115% 80% at 50% 40%, transparent 45%, rgb(251 188 166 / 0.65) 100%), + linear-gradient(180deg, + #fbddb7 0%, #f9e6bc 14%, #f9ebbc 30%, #f9d9b3 52%, + #f7cfac 62%, #f3c19e 70%, #eeae8a 79%, #ee9972 87%, #eb8b63 94%, #ef815c 100%); +} +.hero__orb { position: absolute; inset: 0; width: 100%; height: 100%; display: block; } + +.hero__title { + position: absolute; left: 0; right: 0; top: 24%; + margin: 0; padding: 0 24px; + text-align: center; + font-weight: 400; + font-size: clamp(30px, 3.34vw, 64px); + line-height: 1; + letter-spacing: -0.03em; + color: rgb(var(--ink) / 0.8); + pointer-events: none; +} +.word { display: inline-block; white-space: nowrap; } +.ch { display: inline-block; overflow: hidden; vertical-align: top; padding-bottom: 0.12em; margin-bottom: -0.12em; } +.ch > span { + display: inline-block; + transform: translateY(110%); + transition: transform 0.9s var(--ease); +} +.is-ready .ch > span { transform: none; } + +/* fallback when WebGL is unavailable: a flat glowing sphere */ +.no-webgl .hero::after { + content: ''; position: absolute; left: 50%; top: 52%; + width: 52.5vw; max-width: 1100px; aspect-ratio: 1; transform: translateX(-50%); + border-radius: 50%; + background: radial-gradient(circle at 35% 30%, #fdd29a, #fb9a63 60%, #f06a4a); + box-shadow: 0 0 0 2px rgb(255 235 230 / 0.8), 0 0 80px rgb(255 245 220 / 0.7); +} + +/* ---------- top bar ---------- */ +.bar { position: fixed; z-index: 10; inset: 0 0 auto 0; pointer-events: none; } +.bar > * { pointer-events: auto; } +.bar__logo { + position: absolute; left: calc(14 * var(--u)); top: calc(14 * var(--u)); + opacity: 0; transition: opacity 0.8s var(--ease) 0.2s; +} +.bar__logo img { width: calc(80 * var(--u)); height: calc(80 * var(--u)); min-width: 48px; min-height: 48px; } +.is-ready .bar__logo { opacity: 1; } + +/* ---------- menu ---------- */ +.menu { + position: absolute; right: calc(20 * var(--u)); top: calc(30 * var(--u)); + opacity: 0; transition: opacity 0.8s var(--ease) 0.3s; +} +.is-ready .menu { opacity: 1; } +.menu__btn { + position: relative; z-index: 2; + display: flex; align-items: center; justify-content: center; + width: 40px; height: 40px; + background: #fff; +} +@media (min-width: 768px) { .menu__btn { width: calc(64 * var(--u)); height: calc(64 * var(--u)); min-width: 40px; min-height: 40px; } } +.menu__icon { position: absolute; transition: opacity 0.25s, transform 0.4s var(--ease); } +.menu__icon--close { opacity: 0; transform: rotate(-45deg); } +.is-open .menu__icon--open { opacity: 0; transform: rotate(45deg); } +.is-open .menu__icon--close { opacity: 1; transform: none; } + +.menu__panel { + position: absolute; top: 0; right: 0; z-index: 1; + background: #fff; + width: max-content; max-width: calc(100vw - 24px); + padding: calc(50 * var(--u)) calc(20 * var(--u)) calc(20 * var(--u)); + clip-path: inset(0 0 100% 0); + visibility: hidden; + transition: clip-path 0.5s var(--ease), visibility 0s 0.5s; +} +.is-open .menu__panel { clip-path: inset(0); visibility: visible; transition: clip-path 0.5s var(--ease); } +@media (max-width: 767px) { .menu__panel { width: calc(100vw - 24px); padding: 64px 16px 16px; } } + +.menu__list li { border-bottom: 1px solid rgb(var(--ink) / 0.15); } +.menu__list a { + display: flex; align-items: baseline; + padding: calc(6.5 * var(--u)) calc(60 * var(--u)) calc(6.5 * var(--u)) 0; + font-weight: 500; font-size: calc(18 * var(--u)); line-height: 1.2; white-space: nowrap; + transition: opacity 0.3s; +} +.menu__num { + margin-right: calc(16 * var(--u)); + font-family: 'Geist Mono', ui-monospace, monospace; font-weight: 500; + font-size: calc(14 * var(--u)); text-transform: uppercase; letter-spacing: 0.02em; +} +.menu__list:hover a:not(:hover), .menu__list:focus-within a:not(:focus-visible):not(:hover) { opacity: 0.45; } +.menu__list li, .menu__links { transform: translateY(8px); opacity: 0; transition: transform 0.5s var(--ease), opacity 0.4s; } +.is-open .menu__list li, .is-open .menu__links { transform: none; opacity: 1; } +.is-open .menu__list li:nth-child(1) { transition-delay: 0.10s; } +.is-open .menu__list li:nth-child(2) { transition-delay: 0.14s; } +.is-open .menu__list li:nth-child(3) { transition-delay: 0.18s; } +.is-open .menu__list li:nth-child(4) { transition-delay: 0.22s; } +.is-open .menu__list li:nth-child(5) { transition-delay: 0.26s; } +.is-open .menu__links { transition-delay: 0.32s; } + +.menu__links { display: flex; gap: calc(24 * var(--u)); padding-top: calc(39 * var(--u)); } +.menu__link { + position: relative; display: inline-block; overflow: hidden; + font-family: 'Geist Mono', ui-monospace, monospace; font-weight: 500; + font-size: calc(14 * var(--u)); line-height: 1; text-transform: uppercase; letter-spacing: 0.02em; +} +.menu__link::after { + content: ''; position: absolute; left: 0; bottom: 0; width: 100%; height: 1px; + background: rgb(var(--ink) / 0.3); transform: translateX(-101%); transition: transform 0.4s; +} +.menu__link:hover::after, .menu__link:focus-visible::after { transform: none; } +@media (max-width: 767px) { + .menu__list a { padding: 10px 40px 10px 0; font-size: 18px; } + .menu__num { font-size: 12px; margin-right: 14px; } + .menu__links { gap: 20px; padding-top: 28px; } + .menu__link { font-size: 12px; } + .bar__logo { left: 12px; top: 12px; } + .bar__logo img { width: 44px; height: 44px; } + .menu { right: 12px; top: 12px; } +} + +/* ---------- loader ---------- */ +.loader { + position: fixed; inset: 0; z-index: 1000; + display: flex; flex-direction: column; align-items: center; justify-content: center; + padding: 32px; + background: #fff6f4; + transition: opacity 0.7s var(--ease) 0.1s, visibility 0s 0.8s; +} +.is-ready .loader { opacity: 0; visibility: hidden; } +.loader__stage { flex-grow: 1; display: flex; align-items: center; } +.scene { margin: 80px; width: 140px; height: 140px; perspective: 400px; transform: translateZ(0); } +.cube { + position: relative; width: 140px; height: 140px; + transform-style: preserve-3d; transform: translateZ(-100px); + animation: spin 3s var(--ease) infinite; +} +.cube__face { + position: absolute; width: 140px; height: 140px; + background: rgb(var(--ink)); border: 2px solid rgb(var(--ink)); + display: flex; align-items: center; justify-content: center; +} +.cube__face img { width: 100%; height: 100%; padding: 14px; } +.cube__face--front { transform: rotateY(0) translateZ(70px); } +.cube__face--right { transform: rotateY(90deg) translateZ(70px); } +.cube__face--back { transform: rotateY(180deg) translateZ(70px); } +.cube__face--left { transform: rotateY(-90deg) translateZ(70px); } +.cube__face--top { transform: rotateX(90deg) translateZ(70px); } +.cube__face--bottom { transform: rotateX(-90deg) translateZ(70px); } +.is-ready .cube { animation: none; } +@keyframes spin { + 0% { transform: translateZ(-100px) rotateY(0); } + 40%, 50% { transform: translateZ(-100px) rotateY(-180deg); } + 90%, 100% { transform: translateZ(-100px) rotateY(-360deg); } +} +.loader__count { + margin: 0; flex-shrink: 0; + font-family: 'Geist Mono', ui-monospace, monospace; font-weight: 500; + font-size: 16px; line-height: 1; text-transform: uppercase; color: rgb(var(--ink) / 0.8); +} + +@media (prefers-reduced-motion: reduce) { + .cube { animation: none; } + .ch > span { transform: none; transition: none; } + * { transition-duration: 0.01s !important; } +}