Files
dlStack/assets/js/app.js
Joel Brock 89d7e8d8ca Videos: 2-per-row default, featured opt-in to wide layout
Reverted the global youtube wide treatment per user direction.
Videos default to span 6 (pair layout) at 600px+ again. Items with
`featured: true` in JSON get a new `card--wide` class that opts
them into the same 78%-centered + vertical-breathing layout
portfolio uses — meant to promote a hero video without making
every video imposing.
2026-05-18 20:13:12 -07:00

760 lines
33 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
}[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) {
const wide = it.featured ? " card--wide" : "";
return `
<div class="card card--youtube${wide} 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">&ldquo;</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() {
const root = document.documentElement;
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
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 attachWarpEgg() {
const order = ["editorial", "swiss", "cosmos"];
let buf = "";
const KEY = "warp";
document.addEventListener("keydown", (e) => {
const tgt = e.target;
if (tgt && tgt.matches && tgt.matches("input, textarea, [contenteditable]")) return;
if (!e.key || e.key.length !== 1) return;
buf = (buf + e.key.toLowerCase()).slice(-KEY.length);
if (buf !== KEY) return;
buf = "";
const cur = document.documentElement.dataset.template || "editorial";
const next = order[(order.indexOf(cur) + 1) % order.length];
try { sessionStorage.setItem("dlstack-warp-toast", next); } catch (err) {}
const url = new URL(location.href);
url.searchParams.set("template", next);
location.replace(url.toString());
});
let pending = null;
try { pending = sessionStorage.getItem("dlstack-warp-toast"); } catch (err) {}
if (!pending) return;
try { sessionStorage.removeItem("dlstack-warp-toast"); } catch (err) {}
const toast = document.createElement("div");
toast.className = "warp-toast";
toast.setAttribute("role", "status");
const arrow = document.createElement("span");
arrow.className = "warp-toast__arrow";
arrow.setAttribute("aria-hidden", "true");
arrow.textContent = "↯";
const label = document.createElement("span");
label.className = "warp-toast__label";
label.textContent = pending;
toast.append(arrow, label);
document.body.appendChild(toast);
setTimeout(() => toast.classList.add("warp-toast--out"), 1600);
setTimeout(() => toast.remove(), 2500);
}
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"]);
let urlTpl = null;
try { urlTpl = new URL(location.href).searchParams.get("template"); } catch (e) {}
const tpl = validTpl.has(urlTpl) ? urlTpl
: 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();
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();
attachWarpEgg();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", main);
} else {
main();
}
})();