Cosmos: aurora cursor, card tilt, comets, ripples, turtle; headless sections

Pushed the cosmos theme further:

- Cursor aurora — 600px soft cyan/violet/magenta halo follows the
  pointer with lag (mix-blend-mode: screen, blur 8px). Fades in on
  first move, fades out on pointerleave.
- Card 3D tilt — every cosmos card rotates up to ~6deg toward the
  cursor on hover via custom-property-driven rotateX/rotateY, with a
  cursor-tracking radial shimmer painted inside via mix-blend-mode:
  screen. RAF-throttled. Skipped for testimonial + client tiles since
  they have no chrome.
- Hero parallax — name, tagline, and drift-orb all shift relative to
  cursor position via --cm-x / --cm-y custom properties set globally
  from the pointer move handler.
- Shader: doubled the gravity-well strength (0.03 -> 0.06) and added
  a click-driven ripple — pointerdown sets u_ripple_pos/u_ripple_age
  uniforms; the shader propagates a cyan/magenta luminous ring of
  displacement that decays over ~2s.
- Comets — 3 CSS-only streaks crossing the page on staggered 14/22/
  19s loops with cyan/violet drop-shadow trails.
- Turtle — new .cosmos-turtle img auto-loads if assets/img/turtle.png
  resolves (or theme.turtle URL is set). Floats in from the left,
  arcs across, fades out off the right; pulses its aurora glow on a
  4s bob. Reduced-motion users see it positioned statically.
- All new chrome respects prefers-reduced-motion: halo/comets hidden,
  card tilt + parallax disabled, turtle pinned static.

Also adds section.headless = true (per user request): renders the
section body with no header/kicker chrome and a tighter top margin so
the contents read as nested under the prior section. Example JSON now
uses this to slide the testimonials carousel under the clients wall.
This commit is contained in:
Joel Brock
2026-05-16 09:26:11 -07:00
parent 4868111a14
commit c16ee37096
5 changed files with 305 additions and 16 deletions

View File

@@ -95,10 +95,18 @@ An ordered array of groups. Each section renders with a numbered masthead
"label": "Sites", // required, the section title "label": "Sites", // required, the section title
"kicker": "Where I live online", // optional italic tagline "kicker": "Where I live online", // optional italic tagline
"layout": "clients", // optional — see Layouts below "layout": "clients", // optional — see Layouts below
"headless": true, // optional — render body only, no head
"items": [ ] // required, array of items "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 #### Layouts
| `layout` | When to use | Grid behavior | | `layout` | When to use | Grid behavior |

View File

@@ -52,6 +52,145 @@
} }
@keyframes cosmos-fade-in { to { opacity: 1; } } @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 */ /* CSS-only static fallback nebula when WebGL is unavailable */
:root[data-template="cosmos"].cosmos-static body { :root[data-template="cosmos"].cosmos-static body {
background: background:
@@ -635,10 +774,22 @@
:root[data-template="cosmos"] .marker__brand .star, :root[data-template="cosmos"] .marker__brand .star,
:root[data-template="cosmos"] .hero__asterism, :root[data-template="cosmos"] .hero__asterism,
:root[data-template="cosmos"] .section__head::after, :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; 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 { :root[data-template="cosmos"].cosmos-static body {
background: background:
radial-gradient(ellipse at 30% 20%, rgba(176, 122, 255, 0.25), transparent 60%), radial-gradient(ellipse at 30% 20%, rgba(176, 122, 255, 0.25), transparent 60%),

View File

@@ -186,6 +186,10 @@ body::before {
/* ───── sections ───── */ /* ───── sections ───── */
.section { margin-top: clamp(4rem, 7vw, 6rem); } .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 { .section__head {
display: grid; display: grid;
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;

View File

@@ -167,8 +167,9 @@
const gridClass = isClients ? "grid--clients" : "grid"; const gridClass = isClients ? "grid--clients" : "grid";
body = `<div class="${gridClass}">${items}</div>`; body = `<div class="${gridClass}">${items}</div>`;
} }
return ` const headless = sec.headless === true;
<section class="section"> const cls = `section${headless ? " section--headless" : ""}`;
const head = headless ? "" : `
<header class="section__head"> <header class="section__head">
<div class="section__numwrap"> <div class="section__numwrap">
<small>№</small> <small>№</small>
@@ -178,7 +179,10 @@
<h2 class="section__title">${esc(sec.label)}</h2> <h2 class="section__title">${esc(sec.label)}</h2>
${sec.kicker ? `<span class="section__kicker">${esc(sec.kicker)}</span>` : ""} ${sec.kicker ? `<span class="section__kicker">${esc(sec.kicker)}</span>` : ""}
</div> </div>
</header> </header>`;
return `
<section class="${cls}" ${sec.id ? `id="${esc(sec.id)}"` : ""}>
${head}
${body} ${body}
</section>`; </section>`;
} }
@@ -280,10 +284,15 @@
}); });
} }
function bootCosmos() { function bootCosmos(theme) {
const root = document.documentElement; const root = document.documentElement;
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches; const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
attachCosmosTurtle(theme);
if (reducedMotion) { root.classList.add("cosmos-static"); return; } if (reducedMotion) { root.classList.add("cosmos-static"); return; }
attachCosmosCursor();
attachCosmosComets();
attachCosmosParallax();
attachCosmosCardTilt();
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.className = "cosmos-bg"; canvas.className = "cosmos-bg";
@@ -301,6 +310,8 @@
uniform vec2 u_res; uniform vec2 u_res;
uniform float u_time; uniform float u_time;
uniform vec2 u_mouse; 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 hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
float noise(vec2 p) { float noise(vec2 p) {
@@ -323,11 +334,20 @@
vec2 uv = frag / u_res; vec2 uv = frag / u_res;
vec2 p = (frag - 0.5 * u_res) / u_res.y; 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 m = (u_mouse - 0.5 * u_res) / u_res.y;
vec2 toM = p - m; vec2 toM = p - m;
float md = length(toM) + 0.001; 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 // nebula via warped fbm
vec2 q = p * 1.25 + vec2(u_time * 0.015, u_time * 0.010); 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 = mix(col, magenta, smoothstep(0.55, 0.92, n) * 0.85);
col += cyan * smoothstep(0.78, 0.98, n) * 0.45; 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 // soft vignette
float vig = smoothstep(1.25, 0.30, length(p)); float vig = smoothstep(1.25, 0.30, length(p));
col *= mix(0.55, 1.05, vig); col *= mix(0.55, 1.05, vig);
@@ -414,6 +438,8 @@
const uRes = gl.getUniformLocation(prog, "u_res"); const uRes = gl.getUniformLocation(prog, "u_res");
const uTime = gl.getUniformLocation(prog, "u_time"); const uTime = gl.getUniformLocation(prog, "u_time");
const uMouse = gl.getUniformLocation(prog, "u_mouse"); 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); document.body.prepend(canvas);
@@ -435,6 +461,15 @@
target.y = 1 - e.clientY / window.innerHeight; target.y = 1 - e.clientY / window.innerHeight;
}, { passive: true }); }, { 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; let running = true;
document.addEventListener("visibilitychange", () => { document.addEventListener("visibilitychange", () => {
const wasPaused = !running; const wasPaused = !running;
@@ -442,20 +477,110 @@
if (running && wasPaused) requestAnimationFrame(loop); if (running && wasPaused) requestAnimationFrame(loop);
}); });
const t0 = performance.now();
function loop(now) { function loop(now) {
if (!running) return; if (!running) return;
mouse.x += (target.x - mouse.x) * 0.05; mouse.x += (target.x - mouse.x) * 0.05;
mouse.y += (target.y - mouse.y) * 0.05; mouse.y += (target.y - mouse.y) * 0.05;
const tNow = (now - t0) / 1000;
gl.uniform2f(uRes, w, h); gl.uniform2f(uRes, w, h);
gl.uniform1f(uTime, (now - t0) / 1000); gl.uniform1f(uTime, tNow);
gl.uniform2f(uMouse, mouse.x * w, mouse.y * h); 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); gl.drawArrays(gl.TRIANGLES, 0, 6);
requestAnimationFrame(loop); requestAnimationFrame(loop);
} }
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) { function attachCarousels(root) {
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches; const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
$$("[data-carousel]", root).forEach((car) => { $$("[data-carousel]", root).forEach((car) => {
@@ -554,7 +679,7 @@
const tpl = validTpl.has(data.theme?.template) ? data.theme.template : "editorial"; const tpl = validTpl.has(data.theme?.template) ? data.theme.template : "editorial";
document.documentElement.dataset.template = tpl; document.documentElement.dataset.template = tpl;
try { localStorage.setItem("dlstack-template", tpl); } catch (e) {} try { localStorage.setItem("dlstack-template", tpl); } catch (e) {}
if (tpl === "cosmos") bootCosmos(); if (tpl === "cosmos") bootCosmos(data.theme || {});
const p = data.profile || {}; const p = data.profile || {};
const sections = data.sections || []; const sections = data.sections || [];

View File

@@ -96,6 +96,7 @@
"label": "Testimonials", "label": "Testimonials",
"kicker": "What people say", "kicker": "What people say",
"layout": "testimonials", "layout": "testimonials",
"headless": true,
"items": [ "items": [
{ {
"type": "testimonial", "type": "testimonial",