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.
730 lines
32 KiB
JavaScript
730 lines
32 KiB
JavaScript
/* dlstack — distilled
|
||
* Loads data/links.json and renders sections, cards, YouTube facades.
|
||
* No search, no filter, no clock, no theme toggle. The page is the index.
|
||
*/
|
||
(() => {
|
||
"use strict";
|
||
|
||
const $ = (s, el = document) => el.querySelector(s);
|
||
const $$ = (s, el = document) => Array.from(el.querySelectorAll(s));
|
||
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({
|
||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||
}[c]));
|
||
const frag = (html) => document.createRange().createContextualFragment(html);
|
||
|
||
const hostOf = (url) => {
|
||
try { return new URL(url, location.href).hostname.replace(/^www\./, ""); }
|
||
catch { return ""; }
|
||
};
|
||
const faviconFor = (url) => {
|
||
const h = hostOf(url);
|
||
return h ? `https://www.google.com/s2/favicons?domain=${encodeURIComponent(h)}&sz=128` : "";
|
||
};
|
||
const ytThumb = (id) => `https://i.ytimg.com/vi/${encodeURIComponent(id)}/hqdefault.jpg`;
|
||
|
||
function renderLink(it) {
|
||
const host = hostOf(it.url);
|
||
const custom = it.image || it.icon;
|
||
const src = custom || faviconFor(it.url);
|
||
const isFavicon = !custom;
|
||
const initial = (it.title || host || "·").trim().charAt(0).toUpperCase();
|
||
return `
|
||
<a class="card card--link reveal" href="${esc(it.url)}" target="_blank" rel="noopener noreferrer">
|
||
<span class="favicon" ${src ? "" : "data-fallback"}>
|
||
${src
|
||
? `<img loading="lazy" alt="" src="${esc(src)}" data-fallback-initial="${esc(initial)}"${isFavicon ? ' class="is-favicon"' : ""}>`
|
||
: esc(initial)}
|
||
</span>
|
||
<span>
|
||
<span class="card__title">${esc(it.title)}</span>
|
||
${it.description ? `<span class="card__desc">${esc(it.description)}</span>` : ""}
|
||
<span class="card__meta">
|
||
${host ? `<span class="card__host">${esc(host)}</span>` : ""}
|
||
${dateMarkup(it)}
|
||
</span>
|
||
</span>
|
||
</a>`;
|
||
}
|
||
|
||
function renderProject(it) {
|
||
const featured = it.featured ? " card--featured" : "";
|
||
const tag = it.url ? "a" : "div";
|
||
const attrs = it.url ? `href="${esc(it.url)}" target="_blank" rel="noopener noreferrer"` : "";
|
||
const tags = (it.tags || []).map(t => `<span class="tag">${esc(t)}</span>`).join("");
|
||
return `
|
||
<${tag} class="card card--project${featured} reveal" ${attrs}>
|
||
<span class="card__title">${esc(it.title)}</span>
|
||
${it.description ? `<p class="card__desc">${esc(it.description)}</p>` : ""}
|
||
${(tags || it.date) ? `<div class="tags">${tags}${dateMarkup(it)}</div>` : ""}
|
||
</${tag}>`;
|
||
}
|
||
|
||
function renderYouTube(it) {
|
||
return `
|
||
<div class="card card--youtube reveal">
|
||
<div class="yt" role="button" tabindex="0" aria-label="Play: ${esc(it.title)}" data-yt="${esc(it.id)}" style="background-image:url('${esc(ytThumb(it.id))}')">
|
||
<div class="yt__play" aria-hidden="true">
|
||
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>
|
||
</div>
|
||
<div class="yt__title">${esc(it.title)}${it.date ? ` <span class="yt__date">${esc(fmtDate(it.date))}</span>` : ""}</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderPortfolio(it) {
|
||
const src = it.image || it.url;
|
||
const tag = it.url ? "a" : "div";
|
||
const attrs = it.url ? `href="${esc(it.url)}" target="_blank" rel="noopener noreferrer"` : "";
|
||
const ratio = it.ratio ? ` style="--portfolio-ratio:${esc(String(it.ratio).replace(":", " / "))}"` : "";
|
||
return `
|
||
<${tag} class="card card--portfolio reveal" ${attrs}>
|
||
<div class="portfolio__media"${ratio}>
|
||
${src ? `<img loading="lazy" alt="${esc(it.title || "")}" src="${esc(src)}">` : ""}
|
||
</div>
|
||
${(it.title || it.description || it.date) ? `
|
||
<div class="portfolio__caption">
|
||
${it.title ? `<span class="card__title">${esc(it.title)}</span>` : ""}
|
||
${it.description ? `<span class="card__desc">${esc(it.description)}</span>` : ""}
|
||
${dateMarkup(it)}
|
||
</div>` : ""}
|
||
</${tag}>`;
|
||
}
|
||
|
||
function renderClient(it) {
|
||
const host = hostOf(it.url);
|
||
const custom = it.image || it.icon;
|
||
const src = custom || faviconFor(it.url);
|
||
const isFavicon = !custom;
|
||
const initial = (it.title || host || "·").trim().charAt(0).toUpperCase();
|
||
const tag = it.url ? "a" : "div";
|
||
const attrs = it.url ? `href="${esc(it.url)}" target="_blank" rel="noopener noreferrer"` : "";
|
||
return `
|
||
<${tag} class="card card--client reveal" ${attrs} title="${esc(it.title || host || "")}">
|
||
<span class="client__logo" ${src ? "" : "data-fallback"}>
|
||
${src
|
||
? `<img loading="lazy" alt="${esc(it.title || host || "")}" src="${esc(src)}" data-fallback-initial="${esc(initial)}"${isFavicon ? ' class="is-favicon"' : ""}>`
|
||
: esc(initial)}
|
||
</span>
|
||
${it.title ? `<span class="client__title">${esc(it.title)}</span>` : ""}
|
||
${dateMarkup(it)}
|
||
</${tag}>`;
|
||
}
|
||
|
||
function renderTestimonial(it) {
|
||
const tag = it.url ? "a" : "div";
|
||
const attrs = it.url ? `href="${esc(it.url)}" target="_blank" rel="noopener noreferrer"` : "";
|
||
const avatar = it.image || it.icon || (it.url ? faviconFor(it.url) : "");
|
||
const isFavicon = !(it.image || it.icon);
|
||
const initial = (it.name || it.org || "·").trim().charAt(0).toUpperCase();
|
||
const meta = [it.role, it.org].filter(Boolean).join(", ");
|
||
const len = [...String(it.quote || "")].length;
|
||
const lengthClass =
|
||
len <= 90 ? " is-xshort" :
|
||
len <= 180 ? " is-short" :
|
||
len <= 320 ? " is-medium" :
|
||
len <= 520 ? " is-long" :
|
||
" is-xlong";
|
||
return `
|
||
<${tag} class="card card--testimonial reveal${lengthClass}" ${attrs}>
|
||
<span class="testimonial__mark" aria-hidden="true">“</span>
|
||
<blockquote class="testimonial__quote">${esc(it.quote)}</blockquote>
|
||
<figcaption class="testimonial__by">
|
||
${avatar ? `<span class="testimonial__avatar"><img loading="lazy" alt="" src="${esc(avatar)}" data-fallback-initial="${esc(initial)}"${isFavicon ? ' class="is-favicon"' : ""}></span>` : ""}
|
||
<span class="testimonial__attr">
|
||
${it.name ? `<span class="testimonial__name">${esc(it.name)}</span>` : ""}
|
||
${meta ? `<span class="testimonial__meta">${esc(meta)}</span>` : ""}
|
||
${dateMarkup(it)}
|
||
</span>
|
||
</figcaption>
|
||
</${tag}>`;
|
||
}
|
||
|
||
const renderItem = (it) =>
|
||
it.type === "youtube" ? renderYouTube(it) :
|
||
it.type === "card" ? renderProject(it) :
|
||
it.type === "client" ? renderClient(it) :
|
||
it.type === "portfolio" ? renderPortfolio(it) :
|
||
it.type === "testimonial" ? renderTestimonial(it) :
|
||
renderLink(it);
|
||
|
||
function renderSection(sec, n) {
|
||
const items = (sec.items || []).map(renderItem).join("");
|
||
const num = String(n).padStart(2, "0");
|
||
const isClients = sec.layout === "clients" || sec.items?.[0]?.type === "client";
|
||
const isTestimonials = sec.layout === "testimonials" || sec.items?.[0]?.type === "testimonial";
|
||
let body;
|
||
if (isTestimonials) {
|
||
const count = (sec.items || []).length;
|
||
body = `
|
||
<div class="carousel carousel--inline" data-carousel data-count="${count}">
|
||
${count > 1 ? `<button type="button" class="carousel__nav carousel__nav--prev" aria-label="Previous testimonial">‹</button>` : ""}
|
||
<div class="carousel__viewport">
|
||
<div class="carousel__track" aria-live="polite">${items}</div>
|
||
</div>
|
||
${count > 1 ? `<button type="button" class="carousel__nav carousel__nav--next" aria-label="Next testimonial">›</button>` : ""}
|
||
</div>`;
|
||
} else {
|
||
const gridClass = isClients ? "grid--clients" : "grid";
|
||
body = `<div class="${gridClass}">${items}</div>`;
|
||
}
|
||
const headless = sec.headless === true;
|
||
const cls = `section${headless ? " section--headless" : ""}`;
|
||
const head = headless ? "" : `
|
||
<header class="section__head">
|
||
<div class="section__numwrap">
|
||
<small>№</small>
|
||
<span class="section__num">${num}</span>
|
||
</div>
|
||
<div class="section__titlewrap">
|
||
<h2 class="section__title">${esc(sec.label)}</h2>
|
||
${sec.kicker ? `<span class="section__kicker">${esc(sec.kicker)}</span>` : ""}
|
||
</div>
|
||
</header>`;
|
||
return `
|
||
<section class="${cls}" ${sec.id ? `id="${esc(sec.id)}"` : ""}>
|
||
${head}
|
||
${body}
|
||
</section>`;
|
||
}
|
||
|
||
const SOCIAL_ICONS = {
|
||
github: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5a11.5 11.5 0 0 0-3.64 22.41c.58.1.79-.25.79-.56v-2c-3.2.7-3.87-1.37-3.87-1.37-.52-1.34-1.28-1.7-1.28-1.7-1.05-.71.08-.7.08-.7 1.16.08 1.77 1.2 1.77 1.2 1.03 1.77 2.7 1.26 3.36.96.1-.75.4-1.26.73-1.55-2.55-.29-5.24-1.28-5.24-5.7 0-1.26.45-2.3 1.19-3.1-.12-.3-.52-1.48.11-3.08 0 0 .97-.31 3.18 1.18a11.05 11.05 0 0 1 5.78 0c2.2-1.49 3.17-1.18 3.17-1.18.63 1.6.23 2.78.11 3.08.74.8 1.18 1.84 1.18 3.1 0 4.43-2.69 5.41-5.25 5.69.41.36.78 1.06.78 2.14v3.17c0 .31.2.67.8.56A11.5 11.5 0 0 0 12 .5Z"/></svg>',
|
||
linkedin: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M4.98 3.5A2.5 2.5 0 1 1 4.97 8.5 2.5 2.5 0 0 1 4.98 3.5ZM3 9.75h4V21H3V9.75ZM9.5 9.75h3.8v1.55h.05c.53-1 1.83-2.05 3.77-2.05 4.03 0 4.78 2.65 4.78 6.1V21H18V16.1c0-1.17-.02-2.68-1.63-2.68-1.63 0-1.88 1.27-1.88 2.6V21H9.5V9.75Z"/></svg>',
|
||
mail: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg>',
|
||
rss: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5 3a16 16 0 0 1 16 16h-3A13 13 0 0 0 5 6V3Zm0 7a9 9 0 0 1 9 9h-3a6 6 0 0 0-6-6v-3Zm1.5 6a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Z"/></svg>',
|
||
link: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>',
|
||
calendar: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-calendar-days-icon lucide-calendar-days"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/><path d="M8 14h.01"/><path d="M12 14h.01"/><path d="M16 14h.01"/><path d="M8 18h.01"/><path d="M12 18h.01"/><path d="M16 18h.01"/></svg>',
|
||
bluesky: '<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Bluesky</title><path d="M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026"/></svg>',
|
||
lastfm: '<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Last.fm</title><path d="M10.584 17.21l-.88-2.392s-1.43 1.594-3.573 1.594c-1.897 0-3.244-1.649-3.244-4.288 0-3.382 1.704-4.591 3.381-4.591 2.42 0 3.189 1.567 3.849 3.574l.88 2.749c.88 2.666 2.529 4.81 7.285 4.81 3.409 0 5.718-1.044 5.718-3.793 0-2.227-1.265-3.381-3.63-3.931l-1.758-.385c-1.21-.275-1.567-.77-1.567-1.595 0-.934.742-1.484 1.952-1.484 1.32 0 2.034.495 2.144 1.677l2.749-.33c-.22-2.474-1.924-3.492-4.729-3.492-2.474 0-4.893.935-4.893 3.932 0 1.87.907 3.051 3.189 3.601l1.87.44c1.402.33 1.869.907 1.869 1.704 0 1.017-.99 1.43-2.86 1.43-2.776 0-3.93-1.457-4.59-3.464l-.907-2.75c-1.155-3.573-2.997-4.893-6.653-4.893C2.144 5.333 0 7.89 0 12.233c0 4.18 2.144 6.434 5.993 6.434 3.106 0 4.591-1.457 4.591-1.457z"/></svg>'
|
||
};
|
||
|
||
function renderSocial(items) {
|
||
return items.map(s =>
|
||
`<a href="${esc(s.url)}" target="_blank" rel="noopener noreferrer">${SOCIAL_ICONS[s.icon] || SOCIAL_ICONS.link}<span>${esc(s.label)}</span></a>`
|
||
).join("");
|
||
}
|
||
|
||
function nameMarkup(name) {
|
||
if (!name) return "";
|
||
const parts = String(name).trim().split(/\s+/);
|
||
if (parts.length < 2) return `<span class="hero__name-first">${esc(name)}</span>`;
|
||
const last = parts.pop();
|
||
return `<span class="hero__name-first">${esc(parts.join(" "))}</span> <em data-text="${esc(last)}">${esc(last)}</em>`;
|
||
}
|
||
|
||
function fmtDate(d) {
|
||
if (!d) return "";
|
||
const m = String(d).match(/^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/);
|
||
if (!m) return String(d);
|
||
const [, y, mo, dd] = m;
|
||
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
||
if (dd && mo) return `${months[+mo - 1]} ${+dd}, ${y}`;
|
||
if (mo) return `${months[+mo - 1]} ${y}`;
|
||
return y;
|
||
}
|
||
const dateMarkup = (it) =>
|
||
it.date ? `<time class="card__date" datetime="${esc(it.date)}">${esc(fmtDate(it.date))}</time>` : "";
|
||
|
||
function attachFaviconFallback(root) {
|
||
$$("img[data-fallback-initial]", root).forEach(img => {
|
||
img.addEventListener("error", () => {
|
||
const initial = img.dataset.fallbackInitial || "·";
|
||
const parent = img.parentElement;
|
||
if (parent) { parent.setAttribute("data-fallback", ""); parent.textContent = initial; }
|
||
}, { once: true });
|
||
});
|
||
}
|
||
|
||
function attachYouTube(root) {
|
||
const open = (fac) => {
|
||
const id = fac.dataset.yt;
|
||
if (!id) return;
|
||
const iframe = document.createElement("iframe");
|
||
iframe.src = `https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}?autoplay=1&rel=0&controls=1`;
|
||
iframe.allow = "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share";
|
||
iframe.allowFullscreen = true;
|
||
iframe.title = fac.getAttribute("aria-label") || "YouTube video";
|
||
fac.replaceChildren(iframe);
|
||
fac.classList.add("yt--playing");
|
||
fac.removeAttribute("data-yt");
|
||
fac.removeAttribute("role");
|
||
fac.removeAttribute("tabindex");
|
||
fac.style.cursor = "default";
|
||
};
|
||
root.addEventListener("click", (e) => {
|
||
const fac = e.target.closest("[data-yt]");
|
||
if (fac) { e.preventDefault(); open(fac); }
|
||
});
|
||
root.addEventListener("keydown", (e) => {
|
||
if (e.key !== "Enter" && e.key !== " ") return;
|
||
const fac = e.target.closest("[data-yt]");
|
||
if (fac) { e.preventDefault(); open(fac); }
|
||
});
|
||
}
|
||
|
||
function attachTheme() {
|
||
const root = document.documentElement;
|
||
const btn = $("#theme");
|
||
if (!btn) return;
|
||
const order = ["auto", "light", "dark"];
|
||
const label = { auto: "Auto", light: "Light", dark: "Dark" };
|
||
const stored = localStorage.getItem("dlstack-theme");
|
||
if (!order.includes(root.dataset.theme)) root.dataset.theme = "auto";
|
||
if (order.includes(stored)) root.dataset.theme = stored;
|
||
const sync = () => {
|
||
btn.textContent = label[root.dataset.theme];
|
||
btn.setAttribute("aria-label", `Theme: ${label[root.dataset.theme]}. Click to change.`);
|
||
};
|
||
sync();
|
||
btn.addEventListener("click", () => {
|
||
const i = order.indexOf(root.dataset.theme);
|
||
root.dataset.theme = order[(i + 1) % order.length];
|
||
localStorage.setItem("dlstack-theme", root.dataset.theme);
|
||
sync();
|
||
});
|
||
}
|
||
|
||
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";
|
||
canvas.setAttribute("aria-hidden", "true");
|
||
const gl = canvas.getContext("webgl", { antialias: false, alpha: false, premultipliedAlpha: false }) ||
|
||
canvas.getContext("experimental-webgl");
|
||
if (!gl) { root.classList.add("cosmos-static"); return; }
|
||
|
||
const vsSrc = `
|
||
attribute vec2 a;
|
||
void main() { gl_Position = vec4(a, 0.0, 1.0); }
|
||
`;
|
||
const fsSrc = `
|
||
precision highp float;
|
||
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) {
|
||
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, 0.0)), u.x),
|
||
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);
|
||
}
|
||
float fbm(vec2 p) {
|
||
float v = 0.0, a = 0.55;
|
||
for (int i = 0; i < 5; i++) {
|
||
v += a * noise(p);
|
||
p *= 2.04; a *= 0.5;
|
||
}
|
||
return v;
|
||
}
|
||
|
||
void main() {
|
||
vec2 frag = gl_FragCoord.xy;
|
||
vec2 uv = frag / u_res;
|
||
vec2 p = (frag - 0.5 * u_res) / u_res.y;
|
||
|
||
// 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.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);
|
||
vec2 r = q + vec2(fbm(q + u_time * 0.04), fbm(q - u_time * 0.03));
|
||
float n = fbm(r);
|
||
|
||
// palette: deep indigo -> magenta -> violet -> cyan ridges
|
||
vec3 deep = vec3(0.020, 0.012, 0.055);
|
||
vec3 violet = vec3(0.350, 0.150, 0.620);
|
||
vec3 magenta = vec3(0.950, 0.220, 0.700);
|
||
vec3 cyan = vec3(0.290, 0.940, 1.000);
|
||
|
||
vec3 col = deep;
|
||
col = mix(col, violet, smoothstep(0.25, 0.70, n));
|
||
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);
|
||
|
||
// ───── starfield — two layers ─────
|
||
// layer 1 — small dense
|
||
float starDensity = 90.0;
|
||
vec2 sc = uv * starDensity * vec2(u_res.x / u_res.y, 1.0);
|
||
vec2 si = floor(sc);
|
||
vec2 sf = fract(sc) - 0.5;
|
||
float sh = hash(si);
|
||
if (sh > 0.986) {
|
||
float d = length(sf);
|
||
float tw = 0.55 + 0.45 * sin(u_time * 2.4 + sh * 88.0);
|
||
col += vec3(smoothstep(0.05, 0.0, d) * tw);
|
||
}
|
||
// layer 2 — bright sparse blue-white
|
||
starDensity = 28.0;
|
||
sc = uv * starDensity * vec2(u_res.x / u_res.y, 1.0);
|
||
si = floor(sc);
|
||
sf = fract(sc) - 0.5;
|
||
sh = hash(si + 13.7);
|
||
if (sh > 0.993) {
|
||
float d = length(sf);
|
||
float tw = 0.3 + 0.7 * sin(u_time * 1.7 + sh * 200.0);
|
||
float s = smoothstep(0.09, 0.0, d) * tw;
|
||
col += vec3(0.72, 0.86, 1.0) * s * 1.8;
|
||
// soft halo
|
||
col += vec3(0.45, 0.55, 0.95) * smoothstep(0.28, 0.0, d) * tw * 0.18;
|
||
}
|
||
|
||
gl_FragColor = vec4(col, 1.0);
|
||
}
|
||
`;
|
||
|
||
const compile = (type, src) => {
|
||
const s = gl.createShader(type);
|
||
gl.shaderSource(s, src); gl.compileShader(s);
|
||
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
|
||
console.warn("[cosmos]", gl.getShaderInfoLog(s));
|
||
return null;
|
||
}
|
||
return s;
|
||
};
|
||
const vs = compile(gl.VERTEX_SHADER, vsSrc);
|
||
const fs = compile(gl.FRAGMENT_SHADER, fsSrc);
|
||
if (!vs || !fs) { root.classList.add("cosmos-static"); return; }
|
||
const prog = gl.createProgram();
|
||
gl.attachShader(prog, vs); gl.attachShader(prog, fs);
|
||
gl.linkProgram(prog);
|
||
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
|
||
console.warn("[cosmos]", gl.getProgramInfoLog(prog));
|
||
root.classList.add("cosmos-static"); return;
|
||
}
|
||
gl.useProgram(prog);
|
||
|
||
const buf = gl.createBuffer();
|
||
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
|
||
-1,-1, 1,-1, -1, 1,
|
||
-1, 1, 1,-1, 1, 1
|
||
]), gl.STATIC_DRAW);
|
||
const aLoc = gl.getAttribLocation(prog, "a");
|
||
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 uRipplePos = gl.getUniformLocation(prog, "u_ripple_pos");
|
||
const uRippleAge = gl.getUniformLocation(prog, "u_ripple_age");
|
||
|
||
document.body.prepend(canvas);
|
||
|
||
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
|
||
let w = 0, h = 0;
|
||
const resize = () => {
|
||
w = Math.floor(window.innerWidth * dpr);
|
||
h = Math.floor(window.innerHeight * dpr);
|
||
canvas.width = w; canvas.height = h;
|
||
gl.viewport(0, 0, w, h);
|
||
};
|
||
resize();
|
||
window.addEventListener("resize", resize, { passive: true });
|
||
|
||
const mouse = { x: 0.5, y: 0.5 };
|
||
const target = { x: 0.5, y: 0.5 };
|
||
window.addEventListener("pointermove", (e) => {
|
||
target.x = e.clientX / window.innerWidth;
|
||
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;
|
||
running = !document.hidden;
|
||
if (running && wasPaused) requestAnimationFrame(loop);
|
||
});
|
||
|
||
function loop(now) {
|
||
if (!running) return;
|
||
mouse.x += (target.x - mouse.x) * 0.05;
|
||
mouse.y += (target.y - mouse.y) * 0.05;
|
||
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) => {
|
||
const slides = $$(".carousel__track > *", car);
|
||
const dots = $$(".carousel__dot", car);
|
||
if (slides.length <= 1) { slides.forEach((s) => s.classList.add("is-active")); return; }
|
||
if (reduced) { car.classList.add("carousel--stacked"); slides.forEach((s) => s.classList.add("is-active")); return; }
|
||
|
||
let i = 0;
|
||
const show = (n) => {
|
||
i = (n + slides.length) % slides.length;
|
||
slides.forEach((s, k) => s.classList.toggle("is-active", k === i));
|
||
dots.forEach((d, k) => d.classList.toggle("is-active", k === i));
|
||
car.setAttribute("aria-label", `Testimonial ${i + 1} of ${slides.length}`);
|
||
};
|
||
show(0);
|
||
|
||
let timer = 0;
|
||
const ADVANCE_MS = 7000;
|
||
const play = () => { stop(); timer = setInterval(() => show(i + 1), ADVANCE_MS); };
|
||
const stop = () => { if (timer) { clearInterval(timer); timer = 0; } };
|
||
|
||
car.addEventListener("pointerenter", stop);
|
||
car.addEventListener("pointerleave", play);
|
||
car.addEventListener("focusin", stop);
|
||
car.addEventListener("focusout", play);
|
||
document.addEventListener("visibilitychange", () => { document.hidden ? stop() : play(); });
|
||
|
||
$(".carousel__nav--prev", car)?.addEventListener("click", () => { show(i - 1); play(); });
|
||
$(".carousel__nav--next", car)?.addEventListener("click", () => { show(i + 1); play(); });
|
||
dots.forEach((d) => d.addEventListener("click", () => { show(Number(d.dataset.slide)); play(); }));
|
||
|
||
car.tabIndex = 0;
|
||
car.addEventListener("keydown", (e) => {
|
||
if (e.key === "ArrowLeft") { e.preventDefault(); show(i - 1); play(); }
|
||
if (e.key === "ArrowRight") { e.preventDefault(); show(i + 1); play(); }
|
||
});
|
||
|
||
let touchX = null;
|
||
car.addEventListener("touchstart", (e) => { touchX = e.changedTouches[0].clientX; }, { passive: true });
|
||
car.addEventListener("touchend", (e) => {
|
||
if (touchX === null) return;
|
||
const dx = e.changedTouches[0].clientX - touchX;
|
||
if (Math.abs(dx) > 40) show(dx < 0 ? i + 1 : i - 1);
|
||
touchX = null;
|
||
play();
|
||
});
|
||
|
||
play();
|
||
});
|
||
}
|
||
|
||
function attachReveal(root) {
|
||
if (!("IntersectionObserver" in window)) {
|
||
$$(".reveal", root).forEach(el => el.classList.add("in"));
|
||
return;
|
||
}
|
||
const io = new IntersectionObserver((entries) => {
|
||
entries.forEach(en => {
|
||
if (en.isIntersecting) { en.target.classList.add("in"); io.unobserve(en.target); }
|
||
});
|
||
}, { threshold: 0.08, rootMargin: "0px 0px -40px 0px" });
|
||
$$(".reveal", root).forEach(el => io.observe(el));
|
||
}
|
||
|
||
async function loadData() {
|
||
const sources = ["data/links.json", "data/links.example.json"];
|
||
let lastErr;
|
||
for (const src of sources) {
|
||
try {
|
||
const res = await fetch(src, { cache: "no-cache" });
|
||
if (!res.ok) { lastErr = new Error(`${src}: HTTP ${res.status}`); continue; }
|
||
return await res.json();
|
||
} catch (err) { lastErr = err; }
|
||
}
|
||
throw lastErr || new Error("No data file found.");
|
||
}
|
||
|
||
async function main() {
|
||
const app = $("#app");
|
||
let data;
|
||
try {
|
||
data = await loadData();
|
||
} catch (err) {
|
||
const p = document.createElement("p");
|
||
p.style.cssText = "color:var(--accent);font-family:var(--mono);padding:2rem;max-width:48ch";
|
||
p.textContent = `Couldn't load data/links.json or data/links.example.json. Check that one exists and is valid JSON. Details: ${err.message}`;
|
||
app.replaceChildren(p);
|
||
return;
|
||
}
|
||
|
||
if (data.theme?.accent) {
|
||
document.documentElement.style.setProperty("--accent", data.theme.accent);
|
||
}
|
||
const validTpl = new Set(["editorial", "swiss", "cosmos"]);
|
||
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(data.theme || {});
|
||
|
||
const p = data.profile || {};
|
||
const sections = data.sections || [];
|
||
const social = data.social || [];
|
||
document.title = `${p.name || "Links"}`;
|
||
|
||
const taglineMarkup = (t) =>
|
||
esc(t).replace(/\s*[·•|]\s*/g, '<span aria-hidden="true">·</span>');
|
||
|
||
const html = `
|
||
<div class="marker">
|
||
<span class="marker__brand"><span class="star" aria-hidden="true">✱</span> Index №01</span>
|
||
<span class="marker__year">MMXXVI</span>
|
||
<button id="theme" class="theme" type="button">Auto</button>
|
||
</div>
|
||
|
||
<header class="hero">
|
||
<span class="hero__asterism" aria-hidden="true">✱</span>
|
||
<h1 class="hero__name">${nameMarkup(p.name)}</h1>
|
||
${p.tagline ? `<p class="hero__tagline">${taglineMarkup(p.tagline)}</p>` : ""}
|
||
${p.bio ? `<p class="hero__bio">${esc(p.bio)}</p>` : ""}
|
||
${social.length ? `<nav class="social" aria-label="Social">${renderSocial(social)}</nav>` : ""}
|
||
</header>
|
||
|
||
<main>${sections.map((s, i) => renderSection(s, i + 1)).join("")}</main>
|
||
|
||
<footer class="foot">
|
||
<span>${esc(data.footer?.copy || "")}</span>
|
||
<span class="foot__mark" aria-hidden="true">— ✱ —</span>
|
||
<span class="foot__right">© ${new Date().getFullYear()} ${esc(p.name || "")}</span>
|
||
</footer>
|
||
`;
|
||
app.replaceChildren(frag(html));
|
||
|
||
attachFaviconFallback(app);
|
||
attachYouTube(app);
|
||
attachCarousels(app);
|
||
attachReveal(app);
|
||
attachTheme();
|
||
}
|
||
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", main);
|
||
} else {
|
||
main();
|
||
}
|
||
})();
|