diff --git a/README.md b/README.md index 808cd86..b8c973c 100644 --- a/README.md +++ b/README.md @@ -95,10 +95,18 @@ An ordered array of groups. Each section renders with a numbered masthead "label": "Sites", // required, the section title "kicker": "Where I live online", // optional italic tagline "layout": "clients", // optional — see Layouts below + "headless": true, // optional — render body only, no head "items": [ … ] // required, array of items } ``` +Set `headless: true` to render a section's body without the +`№ NN / Label / kicker` header. Tighter top margin too, so the +contents visually nest under the previous section. Useful for sliding +a testimonials carousel underneath a Clients wall, for example — +put the testimonials section directly after the clients section in +the `sections` array with `headless: true` and they'll read as one. + #### Layouts | `layout` | When to use | Grid behavior | diff --git a/assets/css/cosmos.css b/assets/css/cosmos.css index bbd2f9d..b13b6d1 100644 --- a/assets/css/cosmos.css +++ b/assets/css/cosmos.css @@ -52,6 +52,145 @@ } @keyframes cosmos-fade-in { to { opacity: 1; } } +/* cursor aurora — soft cyan/magenta halo that lags behind the pointer */ +:root[data-template="cosmos"] .cosmos-halo { + position: fixed; top: 0; left: 0; + width: 600px; height: 600px; + border-radius: 50%; + pointer-events: none; + z-index: 2; + background: + radial-gradient(circle at 50% 50%, + rgba(122, 247, 255, 0.20) 0%, + rgba(176, 122, 255, 0.14) 18%, + rgba(255, 78, 205, 0.10) 38%, + transparent 65%); + mix-blend-mode: screen; + filter: blur(8px); + opacity: 0; + transition: opacity 700ms cubic-bezier(0.22, 1, 0.36, 1); + will-change: transform, opacity; +} +:root[data-template="cosmos"].cosmos-cursor .cosmos-halo { opacity: 1; } + +/* periodic comets — three streaks crossing the page */ +:root[data-template="cosmos"] .cosmos-comet { + position: fixed; top: 0; left: 0; + width: clamp(120px, 12vw, 200px); + height: 2px; + pointer-events: none; + z-index: 1; + background: linear-gradient(90deg, + transparent 0%, + rgba(255,255,255,0.85) 60%, + var(--accent) 85%, + transparent 100%); + filter: drop-shadow(0 0 6px var(--accent)) drop-shadow(0 0 18px var(--accent-3)); + opacity: 0; + transform-origin: right center; +} +:root[data-template="cosmos"] .cosmos-comet--1 { animation: cosmos-comet-a 14s 4s ease-in infinite; } +:root[data-template="cosmos"] .cosmos-comet--2 { animation: cosmos-comet-b 22s 11s ease-in infinite; } +:root[data-template="cosmos"] .cosmos-comet--3 { animation: cosmos-comet-c 19s 19s ease-in infinite; } +@keyframes cosmos-comet-a { + 0% { transform: translate(-20vw, 12vh) rotate(28deg) scaleX(0.4); opacity: 0; } + 4% { opacity: 1; } + 18% { transform: translate(110vw, 70vh) rotate(28deg) scaleX(1); opacity: 0; } + 100% { opacity: 0; } +} +@keyframes cosmos-comet-b { + 0% { transform: translate(115vw, 8vh) rotate(158deg) scaleX(0.4); opacity: 0; } + 5% { opacity: 1; } + 22% { transform: translate(-20vw, 65vh) rotate(158deg) scaleX(1); opacity: 0; } + 100% { opacity: 0; } +} +@keyframes cosmos-comet-c { + 0% { transform: translate(-20vw, 50vh) rotate(45deg) scaleX(0.4); opacity: 0; } + 3% { opacity: 1; } + 16% { transform: translate(110vw, -10vh) rotate(45deg) scaleX(1); opacity: 0; } + 100% { opacity: 0; } +} + +/* card 3D tilt + cursor-tracking holographic shimmer */ +:root[data-template="cosmos"] .card { transform-style: preserve-3d; perspective: 1000px; } +:root[data-template="cosmos"] .card:hover { + transform: + translateY(-4px) + rotateX(calc(var(--ty, 0) * -6deg)) + rotateY(calc(var(--tx, 0) * 6deg)); +} +:root[data-template="cosmos"] .card .card__shimmer { + position: absolute; inset: 0; + pointer-events: none; + border-radius: inherit; + background: radial-gradient(circle 220px at var(--sx, 50%) var(--sy, 50%), + rgba(122, 247, 255, 0.20), + rgba(176, 122, 255, 0.10) 35%, + transparent 70%); + opacity: 0; + mix-blend-mode: screen; + transition: opacity 350ms cubic-bezier(0.22, 1, 0.36, 1); + z-index: 0; +} +:root[data-template="cosmos"] .card:hover .card__shimmer { opacity: 1; } +:root[data-template="cosmos"] .card > * { position: relative; z-index: 1; } + +/* hero parallax — name shifts counter to cursor, orb drifts with it */ +:root[data-template="cosmos"] .hero__name { + transform: + translate3d(calc(var(--cm-x, 0) * -10px), calc(var(--cm-y, 0) * -6px), 0); + transition: transform 500ms cubic-bezier(0.22, 1, 0.36, 1); + will-change: transform; +} +:root[data-template="cosmos"] .hero__asterism { + /* override the base drift to include parallax */ + animation: cosmos-orb-rise 1400ms 300ms cubic-bezier(0.16, 1, 0.30, 1) both; + transform: translate3d(calc(var(--cm-x, 0) * 16px), calc(var(--cm-y, 0) * 10px), 0); + transition: transform 700ms cubic-bezier(0.22, 1, 0.36, 1); +} +:root[data-template="cosmos"] .hero__tagline { + transform: translate3d(calc(var(--cm-x, 0) * -4px), calc(var(--cm-y, 0) * -2px), 0); + transition: transform 600ms cubic-bezier(0.22, 1, 0.36, 1); +} + +/* galactic space turtle — floats in from the left, dips, then back out */ +:root[data-template="cosmos"] .cosmos-turtle { + position: fixed; + top: clamp(48px, 9vh, 110px); + left: 0; + width: clamp(140px, 16vw, 240px); + height: auto; + z-index: 3; + pointer-events: none; + opacity: 0; + filter: + drop-shadow(0 0 22px rgba(122, 247, 255, 0.55)) + drop-shadow(0 0 48px rgba(176, 122, 255, 0.45)) + drop-shadow(0 0 80px rgba(255, 78, 205, 0.25)); + animation: + cosmos-turtle-cycle 28s 2s linear infinite, + cosmos-turtle-bob 4s ease-in-out infinite; + will-change: transform, opacity; +} +@keyframes cosmos-turtle-cycle { + 0% { transform: translate(-25vw, 0) rotate(-6deg); opacity: 0; } + 6% { opacity: 1; } + 45% { transform: translate(45vw, -4vh) rotate(4deg); opacity: 1; } + 88% { transform: translate(112vw, 0) rotate(8deg); opacity: 1; } + 93% { opacity: 0; } + 100% { transform: translate(112vw, 0) rotate(8deg); opacity: 0; } +} +@keyframes cosmos-turtle-bob { + 0%, 100% { filter: + drop-shadow(0 0 22px rgba(122, 247, 255, 0.55)) + drop-shadow(0 0 48px rgba(176, 122, 255, 0.45)) + drop-shadow(0 0 80px rgba(255, 78, 205, 0.25)); } + 50% { filter: + drop-shadow(0 0 32px rgba(122, 247, 255, 0.75)) + drop-shadow(0 0 64px rgba(176, 122, 255, 0.60)) + drop-shadow(0 0 110px rgba(255, 78, 205, 0.40)); } +} + /* CSS-only static fallback nebula when WebGL is unavailable */ :root[data-template="cosmos"].cosmos-static body { background: @@ -635,10 +774,22 @@ :root[data-template="cosmos"] .marker__brand .star, :root[data-template="cosmos"] .hero__asterism, :root[data-template="cosmos"] .section__head::after, - :root[data-template="cosmos"] .card::before { + :root[data-template="cosmos"] .card::before, + :root[data-template="cosmos"] .cosmos-comet, + :root[data-template="cosmos"] .cosmos-turtle { animation: none !important; + transition: none !important; } - :root[data-template="cosmos"] .cosmos-bg { display: none; } + :root[data-template="cosmos"] .cosmos-bg, + :root[data-template="cosmos"] .cosmos-halo, + :root[data-template="cosmos"] .cosmos-comet { display: none; } + :root[data-template="cosmos"] .cosmos-turtle { + opacity: 1; + transform: translate(2vw, 0) rotate(-2deg); + } + :root[data-template="cosmos"] .hero__name, + :root[data-template="cosmos"] .hero__tagline, + :root[data-template="cosmos"] .hero__asterism { transform: none !important; } :root[data-template="cosmos"].cosmos-static body { background: radial-gradient(ellipse at 30% 20%, rgba(176, 122, 255, 0.25), transparent 60%), diff --git a/assets/css/styles.css b/assets/css/styles.css index 363556a..7a464d9 100644 --- a/assets/css/styles.css +++ b/assets/css/styles.css @@ -186,6 +186,10 @@ body::before { /* ───── sections ───── */ .section { margin-top: clamp(4rem, 7vw, 6rem); } +.section--headless { + /* nest visually under the previous section instead of starting a new one */ + margin-top: clamp(1.25rem, 2.5vw, 2rem); +} .section__head { display: grid; grid-template-columns: auto 1fr; diff --git a/assets/js/app.js b/assets/js/app.js index beb9bb6..9e335bc 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -167,8 +167,9 @@ const gridClass = isClients ? "grid--clients" : "grid"; body = `
${items}
`; } - return ` -
+ const headless = sec.headless === true; + const cls = `section${headless ? " section--headless" : ""}`; + const head = headless ? "" : `
@@ -178,7 +179,10 @@

${esc(sec.label)}

${sec.kicker ? `${esc(sec.kicker)}` : ""}
-
+ `; + return ` +
+ ${head} ${body}
`; } @@ -280,10 +284,15 @@ }); } - function bootCosmos() { + function bootCosmos(theme) { const root = document.documentElement; const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches; + attachCosmosTurtle(theme); if (reducedMotion) { root.classList.add("cosmos-static"); return; } + attachCosmosCursor(); + attachCosmosComets(); + attachCosmosParallax(); + attachCosmosCardTilt(); const canvas = document.createElement("canvas"); canvas.className = "cosmos-bg"; @@ -301,6 +310,8 @@ uniform vec2 u_res; uniform float u_time; uniform vec2 u_mouse; + uniform vec2 u_ripple_pos; + uniform float u_ripple_age; float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } float noise(vec2 p) { @@ -323,11 +334,20 @@ vec2 uv = frag / u_res; vec2 p = (frag - 0.5 * u_res) / u_res.y; - // gravity well: pull sampling toward cursor very subtly + // gravity well: pull sampling toward cursor — stronger now vec2 m = (u_mouse - 0.5 * u_res) / u_res.y; vec2 toM = p - m; float md = length(toM) + 0.001; - p -= (toM / md) * 0.03 * exp(-md * 1.6); + p -= (toM / md) * 0.06 * exp(-md * 1.2); + + // click ripple — propagating ring of displacement and brightness + vec2 rp = (u_ripple_pos - 0.5 * u_res) / u_res.y; + float rd = length(p - rp); + float rt = u_ripple_age; + float ringR = rt * 1.4; + float ringW = 0.16 + rt * 0.05; + float ring = exp(-pow((rd - ringR) / ringW, 2.0)) * exp(-rt * 0.7); + p -= normalize(p - rp + vec2(0.0001)) * ring * 0.10; // nebula via warped fbm vec2 q = p * 1.25 + vec2(u_time * 0.015, u_time * 0.010); @@ -345,6 +365,10 @@ col = mix(col, magenta, smoothstep(0.55, 0.92, n) * 0.85); col += cyan * smoothstep(0.78, 0.98, n) * 0.45; + // ripple adds a luminous cyan ring + col += cyan * ring * 0.85; + col += vec3(1.0, 0.6, 0.95) * ring * 0.35; + // soft vignette float vig = smoothstep(1.25, 0.30, length(p)); col *= mix(0.55, 1.05, vig); @@ -411,9 +435,11 @@ gl.enableVertexAttribArray(aLoc); gl.vertexAttribPointer(aLoc, 2, gl.FLOAT, false, 0, 0); - const uRes = gl.getUniformLocation(prog, "u_res"); - const uTime = gl.getUniformLocation(prog, "u_time"); - const uMouse = gl.getUniformLocation(prog, "u_mouse"); + const uRes = gl.getUniformLocation(prog, "u_res"); + const uTime = gl.getUniformLocation(prog, "u_time"); + const uMouse = gl.getUniformLocation(prog, "u_mouse"); + const uRipplePos = gl.getUniformLocation(prog, "u_ripple_pos"); + const uRippleAge = gl.getUniformLocation(prog, "u_ripple_age"); document.body.prepend(canvas); @@ -435,6 +461,15 @@ target.y = 1 - e.clientY / window.innerHeight; }, { passive: true }); + const t0 = performance.now(); + const ripple = { x: 0, y: 0, t0: -1e6 }; + window.addEventListener("pointerdown", (e) => { + const dpr2 = Math.min(window.devicePixelRatio || 1, 1.5); + ripple.x = e.clientX * dpr2; + ripple.y = (window.innerHeight - e.clientY) * dpr2; + ripple.t0 = (performance.now() - t0) / 1000; + }, { passive: true }); + let running = true; document.addEventListener("visibilitychange", () => { const wasPaused = !running; @@ -442,20 +477,110 @@ if (running && wasPaused) requestAnimationFrame(loop); }); - const t0 = performance.now(); function loop(now) { if (!running) return; mouse.x += (target.x - mouse.x) * 0.05; mouse.y += (target.y - mouse.y) * 0.05; - gl.uniform2f(uRes, w, h); - gl.uniform1f(uTime, (now - t0) / 1000); - gl.uniform2f(uMouse, mouse.x * w, mouse.y * h); + const tNow = (now - t0) / 1000; + gl.uniform2f(uRes, w, h); + gl.uniform1f(uTime, tNow); + gl.uniform2f(uMouse, mouse.x * w, mouse.y * h); + gl.uniform2f(uRipplePos, ripple.x, ripple.y); + gl.uniform1f(uRippleAge, tNow - ripple.t0); gl.drawArrays(gl.TRIANGLES, 0, 6); requestAnimationFrame(loop); } requestAnimationFrame(loop); } + /* ───── cosmos overlays + interactions ───── */ + function attachCosmosCursor() { + const root = document.documentElement; + const halo = document.createElement("div"); + halo.className = "cosmos-halo"; + halo.setAttribute("aria-hidden", "true"); + document.body.appendChild(halo); + + let x = window.innerWidth / 2, y = window.innerHeight / 2; + let tx = x, ty = y; + let active = false; + const wakeUp = () => { if (!active) { active = true; root.classList.add("cosmos-cursor"); } }; + window.addEventListener("pointermove", (e) => { + tx = e.clientX; ty = e.clientY; wakeUp(); + const nx = (e.clientX / window.innerWidth) - 0.5; + const ny = (e.clientY / window.innerHeight) - 0.5; + root.style.setProperty("--cm-x", nx.toFixed(3)); + root.style.setProperty("--cm-y", ny.toFixed(3)); + }, { passive: true }); + window.addEventListener("pointerleave", () => root.classList.remove("cosmos-cursor")); + + (function tick() { + x += (tx - x) * 0.14; + y += (ty - y) * 0.14; + halo.style.transform = `translate3d(${x - 300}px, ${y - 300}px, 0)`; + requestAnimationFrame(tick); + })(); + } + + function attachCosmosComets() { + const frag = document.createDocumentFragment(); + for (let i = 1; i <= 3; i++) { + const c = document.createElement("div"); + c.className = `cosmos-comet cosmos-comet--${i}`; + c.setAttribute("aria-hidden", "true"); + frag.appendChild(c); + } + document.body.appendChild(frag); + } + + function attachCosmosParallax() { + /* CSS reads --cm-x / --cm-y set in attachCosmosCursor */ + } + + function attachCosmosCardTilt() { + document.querySelectorAll(".card").forEach((card) => { + if (card.classList.contains("card--testimonial")) return; + if (card.classList.contains("card--client")) return; + const sh = document.createElement("span"); + sh.className = "card__shimmer"; + sh.setAttribute("aria-hidden", "true"); + card.prepend(sh); + let raf = 0; + card.addEventListener("pointermove", (e) => { + if (raf) return; + raf = requestAnimationFrame(() => { + const rect = card.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + const y = (e.clientY - rect.top) / rect.height; + card.style.setProperty("--tx", (x - 0.5).toFixed(3)); + card.style.setProperty("--ty", (y - 0.5).toFixed(3)); + card.style.setProperty("--sx", `${(x * 100).toFixed(1)}%`); + card.style.setProperty("--sy", `${(y * 100).toFixed(1)}%`); + raf = 0; + }); + }); + card.addEventListener("pointerleave", () => { + card.style.setProperty("--tx", "0"); + card.style.setProperty("--ty", "0"); + }); + }); + } + + function attachCosmosTurtle(theme) { + const src = (theme && theme.turtle) || "assets/img/turtle.png"; + const probe = new Image(); + probe.onload = () => { + const t = document.createElement("img"); + t.src = src; + t.alt = ""; + t.className = "cosmos-turtle"; + t.setAttribute("aria-hidden", "true"); + document.body.appendChild(t); + }; + probe.onerror = () => {}; + probe.src = src; + } + function attachCarousels(root) { const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches; $$("[data-carousel]", root).forEach((car) => { @@ -554,7 +679,7 @@ const tpl = validTpl.has(data.theme?.template) ? data.theme.template : "editorial"; document.documentElement.dataset.template = tpl; try { localStorage.setItem("dlstack-template", tpl); } catch (e) {} - if (tpl === "cosmos") bootCosmos(); + if (tpl === "cosmos") bootCosmos(data.theme || {}); const p = data.profile || {}; const sections = data.sections || []; diff --git a/data/links.example.json b/data/links.example.json index 158e1d8..1335054 100644 --- a/data/links.example.json +++ b/data/links.example.json @@ -96,6 +96,7 @@ "label": "Testimonials", "kicker": "What people say", "layout": "testimonials", + "headless": true, "items": [ { "type": "testimonial",