render an ASCII torus behind thicker glass, drop the light theme
- Replace the flat dither backdrop with a lit, rotating torus drawn the way donut.c does it: a z-buffer per cell and a luminance ramp from a dot to a solid block, sized against the shorter viewport axis. The dither field stays underneath as dust. It leads on the landing page and fades back where there is reading to do, and a scrim keeps the projects heading legible over it. - Thicken the glass: one .surface treatment with a heavier blur, more transparency and an SVG film grain, shared by the header, hero, cards, panels and footer. - Give the landing page labelled contact cards for LinkedIn, Gitea and email in place of three bare icons. - Drop the status chips from project cards and entry headers. - Remove the light theme, its toggle and theme.js outright. - Add restrained motion: underlines growing from the left on links and nav items, a lift on cards, buttons and contact cards, and tags brightening with their card. All of it is off under prefers-reduced-motion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
54c00bf16f
commit
7078cda6c5
10 files changed
+448
-415
No files matched your search
+161
-64
@@ -8,54 +8,62 @@
|
||||
const context = canvas.getContext("2d", { alpha: true });
|
||||
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
|
||||
const CHARS = "·.:-=+*#";
|
||||
const CELL_WIDTH = 14;
|
||||
const CELL_HEIGHT = 19;
|
||||
const FRAME_MS = 1000 / 18;
|
||||
const BASE_DENSITY = 0.09;
|
||||
const WAVE_DENSITY = 0.24;
|
||||
const CELL_WIDTH = 13;
|
||||
const CELL_HEIGHT = 17;
|
||||
const ASPECT = CELL_WIDTH / CELL_HEIGHT;
|
||||
const FRAME_MS = 1000 / 24;
|
||||
|
||||
// Ambient dither behind everything.
|
||||
const DUST = "·.:-=+*";
|
||||
const DUST_BASE = 0.05;
|
||||
const DUST_WAVE = 0.16;
|
||||
|
||||
// Lit torus, shaded the way donut.c does it: a luminance ramp from a dot to a
|
||||
// solid block, with a z-buffer so the near face hides the far one.
|
||||
const RAMP = ".,-~:;=!*#$@";
|
||||
const R1 = 1;
|
||||
const R2 = 2;
|
||||
const K2 = 5;
|
||||
const THETA_STEPS = 120;
|
||||
const PHI_STEPS = 360;
|
||||
|
||||
let columns = 0;
|
||||
let rows = 0;
|
||||
let dither = new Float32Array(0);
|
||||
let baseColor = "rgba(255, 255, 255, 1)";
|
||||
let accentColor = "rgba(235, 82, 52, 1)";
|
||||
let depth = new Float32Array(0);
|
||||
let shade = new Int8Array(0);
|
||||
|
||||
const thetaCos = new Float32Array(THETA_STEPS);
|
||||
const thetaSin = new Float32Array(THETA_STEPS);
|
||||
const phiCos = new Float32Array(PHI_STEPS);
|
||||
const phiSin = new Float32Array(PHI_STEPS);
|
||||
|
||||
for (let i = 0; i < THETA_STEPS; i += 1) {
|
||||
const theta = (i / THETA_STEPS) * Math.PI * 2;
|
||||
thetaCos[i] = Math.cos(theta);
|
||||
thetaSin[i] = Math.sin(theta);
|
||||
}
|
||||
|
||||
for (let i = 0; i < PHI_STEPS; i += 1) {
|
||||
const phi = (i / PHI_STEPS) * Math.PI * 2;
|
||||
phiCos[i] = Math.cos(phi);
|
||||
phiSin[i] = Math.sin(phi);
|
||||
}
|
||||
|
||||
let baseColor = "#8e97a8";
|
||||
let accentColor = "#eb5234";
|
||||
let dimColor = "#5d6472";
|
||||
let time = 0;
|
||||
let angleA = 0.6;
|
||||
let angleB = 0.9;
|
||||
let lastFrame = 0;
|
||||
let rafId = 0;
|
||||
|
||||
// Interfering waves, centred on the viewport, give the field slow bright and
|
||||
// dark swells. Which cells are lit comes from a fixed per-cell dither value,
|
||||
// so the texture stays even everywhere instead of pooling into blobs, and
|
||||
// cells brighten as a swell passes rather than flickering at random.
|
||||
const waveAt = (x, y, t) => {
|
||||
const radius = Math.sqrt(
|
||||
((x - columns / 2) * 0.55) ** 2 + (y - rows / 2) ** 2
|
||||
);
|
||||
|
||||
const a = Math.sin(x * 0.068 + t * 0.55);
|
||||
const b = Math.sin(y * 0.1 - t * 0.42);
|
||||
const c = Math.sin((x * 0.4 + y * 0.6) * 0.07 + t * 0.33);
|
||||
const d = Math.sin(radius * 0.1 - t * 0.6);
|
||||
|
||||
return (a + b + c + d) / 8 + 0.5;
|
||||
};
|
||||
|
||||
const buildDither = () => {
|
||||
dither = new Float32Array(columns * rows);
|
||||
|
||||
for (let row = 0; row < rows; row += 1) {
|
||||
for (let column = 0; column < columns; column += 1) {
|
||||
const noise = Math.sin(column * 127.1 + row * 311.7) * 43758.5453;
|
||||
dither[row * columns + column] = noise - Math.floor(noise);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const readThemeColors = () => {
|
||||
const styles = getComputedStyle(document.body);
|
||||
baseColor = styles.getPropertyValue("--ascii-base").trim() || baseColor;
|
||||
accentColor = styles.getPropertyValue("--ascii-accent").trim() || accentColor;
|
||||
dimColor = styles.getPropertyValue("--ascii-dim").trim() || dimColor;
|
||||
};
|
||||
|
||||
const resize = () => {
|
||||
@@ -74,38 +82,131 @@
|
||||
|
||||
columns = Math.ceil(width / CELL_WIDTH);
|
||||
rows = Math.ceil(height / CELL_HEIGHT);
|
||||
buildDither();
|
||||
};
|
||||
|
||||
const draw = () => {
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
|
||||
context.save();
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.restore();
|
||||
|
||||
let currentColor = null;
|
||||
const cells = columns * rows;
|
||||
dither = new Float32Array(cells);
|
||||
depth = new Float32Array(cells);
|
||||
shade = new Int8Array(cells);
|
||||
|
||||
for (let row = 0; row < rows; row += 1) {
|
||||
for (let column = 0; column < columns; column += 1) {
|
||||
const level = waveAt(column, row, time);
|
||||
const noise = Math.sin(column * 127.1 + row * 311.7) * 43758.5453;
|
||||
dither[row * columns + column] = noise - Math.floor(noise);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (dither[row * columns + column] > BASE_DENSITY + level * WAVE_DENSITY) {
|
||||
// Slow interfering swells that move brightness through the dust layer.
|
||||
const swellAt = (x, y, t) => {
|
||||
const radius = Math.sqrt(((x - columns / 2) * 0.55) ** 2 + (y - rows / 2) ** 2);
|
||||
|
||||
return (
|
||||
(Math.sin(x * 0.068 + t * 0.55) +
|
||||
Math.sin(y * 0.1 - t * 0.42) +
|
||||
Math.sin((x * 0.4 + y * 0.6) * 0.07 + t * 0.33) +
|
||||
Math.sin(radius * 0.1 - t * 0.6)) /
|
||||
8 +
|
||||
0.5
|
||||
);
|
||||
};
|
||||
|
||||
const projectTorus = () => {
|
||||
depth.fill(0);
|
||||
shade.fill(-1);
|
||||
|
||||
const sinA = Math.sin(angleA);
|
||||
const cosA = Math.cos(angleA);
|
||||
const sinB = Math.sin(angleB);
|
||||
const cosB = Math.cos(angleB);
|
||||
|
||||
const centerX = columns / 2;
|
||||
const centerY = rows / 2;
|
||||
// Size the torus against the shorter axis so it always fits on screen.
|
||||
const span = Math.min(rows, columns * ASPECT) * 0.6;
|
||||
const K1 = (span * K2) / (R1 + R2);
|
||||
|
||||
for (let i = 0; i < THETA_STEPS; i += 1) {
|
||||
const ct = thetaCos[i];
|
||||
const st = thetaSin[i];
|
||||
const circleX = R2 + R1 * ct;
|
||||
const circleY = R1 * st;
|
||||
|
||||
for (let j = 0; j < PHI_STEPS; j += 1) {
|
||||
const cp = phiCos[j];
|
||||
const sp = phiSin[j];
|
||||
|
||||
const x = circleX * (cosB * cp + sinA * sinB * sp) - circleY * cosA * sinB;
|
||||
const y = circleX * (sinB * cp - sinA * cosB * sp) + circleY * cosA * cosB;
|
||||
const z = K2 + cosA * circleX * sp + circleY * sinA;
|
||||
const ooz = 1 / z;
|
||||
|
||||
const screenX = Math.round(centerX + K1 * ooz * x);
|
||||
const screenY = Math.round(centerY - K1 * ooz * y * ASPECT);
|
||||
|
||||
if (screenX < 0 || screenX >= columns || screenY < 0 || screenY >= rows) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const glyph = CHARS[Math.min(CHARS.length - 1, Math.floor(level * CHARS.length))];
|
||||
const color = level > 0.9 ? accentColor : baseColor;
|
||||
const luminance =
|
||||
cp * ct * sinB -
|
||||
cosA * ct * sp -
|
||||
sinA * st +
|
||||
cosB * (cosA * st - ct * sinA * sp);
|
||||
|
||||
if (color !== currentColor) {
|
||||
context.fillStyle = color;
|
||||
currentColor = color;
|
||||
if (luminance <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
context.globalAlpha = 0.06 + level * 0.5;
|
||||
context.fillText(glyph, column * CELL_WIDTH, row * CELL_HEIGHT);
|
||||
const index = screenY * columns + screenX;
|
||||
|
||||
if (ooz > depth[index]) {
|
||||
depth[index] = ooz;
|
||||
shade[index] = Math.min(RAMP.length - 1, Math.floor(luminance * 8));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const draw = () => {
|
||||
context.save();
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.restore();
|
||||
|
||||
projectTorus();
|
||||
|
||||
let currentColor = null;
|
||||
const setColor = (color) => {
|
||||
if (color !== currentColor) {
|
||||
context.fillStyle = color;
|
||||
currentColor = color;
|
||||
}
|
||||
};
|
||||
|
||||
for (let row = 0; row < rows; row += 1) {
|
||||
for (let column = 0; column < columns; column += 1) {
|
||||
const index = row * columns + column;
|
||||
const lit = shade[index];
|
||||
const x = column * CELL_WIDTH;
|
||||
const y = row * CELL_HEIGHT;
|
||||
|
||||
if (lit >= 0) {
|
||||
const level = lit / (RAMP.length - 1);
|
||||
setColor(level > 0.72 ? accentColor : baseColor);
|
||||
context.globalAlpha = 0.22 + level * 0.62;
|
||||
context.fillText(RAMP[lit], x, y);
|
||||
continue;
|
||||
}
|
||||
|
||||
const swell = swellAt(column, row, time);
|
||||
|
||||
if (dither[index] > DUST_BASE + swell * DUST_WAVE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
setColor(dimColor);
|
||||
context.globalAlpha = 0.1 + swell * 0.4;
|
||||
context.fillText(DUST[Math.min(DUST.length - 1, Math.floor(swell * DUST.length))], x, y);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +221,9 @@
|
||||
}
|
||||
|
||||
lastFrame = timestamp;
|
||||
time += 0.021;
|
||||
time += 0.016;
|
||||
angleA += 0.0115;
|
||||
angleB += 0.0062;
|
||||
draw();
|
||||
};
|
||||
|
||||
@@ -155,12 +258,6 @@
|
||||
resizeTimer = window.setTimeout(reset, 150);
|
||||
});
|
||||
|
||||
// The theme toggle swaps a class on <body>; re-read the palette when it does.
|
||||
new MutationObserver(() => {
|
||||
readThemeColors();
|
||||
draw();
|
||||
}).observe(document.body, { attributes: true, attributeFilter: ["class"] });
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) {
|
||||
stop();
|
||||
|
||||
@@ -147,30 +147,15 @@
|
||||
return `<div class="${className.replace("card-link", "card-links")}">${links.join("")}</div>`;
|
||||
};
|
||||
|
||||
const statusState = (entry) => {
|
||||
const status = String(entry.status || "").toLowerCase();
|
||||
|
||||
if (status.startsWith("active")) {
|
||||
return "active";
|
||||
}
|
||||
|
||||
if (entry.type === "updates") {
|
||||
return "attention";
|
||||
}
|
||||
|
||||
return "idle";
|
||||
};
|
||||
|
||||
const createCard = (entry) => {
|
||||
const featuredClass = entry.featured ? " featured" : "";
|
||||
const anchorId = entry.slug ? ` id="${escapeHtml(entry.slug)}"` : "";
|
||||
|
||||
return `
|
||||
<article class="content-card${featuredClass}"${anchorId}>
|
||||
<article class="content-card surface${featuredClass}"${anchorId}>
|
||||
<div class="card-meta">
|
||||
<span class="card-kicker">${escapeHtml(entry.label || entry.type)}</span>
|
||||
${entry.date ? `<span class="card-date">${escapeHtml(entry.date)}</span>` : ""}
|
||||
${entry.status ? `<span class="card-status" data-state="${statusState(entry)}">${escapeHtml(entry.status.toLowerCase())}</span>` : ""}
|
||||
</div>
|
||||
<h3>${escapeHtml(entry.title)}</h3>
|
||||
<p class="card-summary">${escapeHtml(entry.summary || "")}</p>
|
||||
@@ -336,7 +321,6 @@
|
||||
<div class="entry-meta">
|
||||
<span class="entry-pill entry-pill-primary">${escapeHtml(entry.label || entry.type)}</span>
|
||||
${entry.date ? `<span class="entry-pill entry-pill-secondary">${escapeHtml(formatDate(entry.date))}</span>` : ""}
|
||||
${entry.status ? `<span class="entry-pill entry-pill-secondary">${escapeHtml(entry.status)}</span>` : ""}
|
||||
</div>
|
||||
<h1 class="entry-title">${escapeHtml(entry.title)}</h1>
|
||||
<p class="entry-summary">${escapeHtml(entry.summary || "")}</p>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
(function() {
|
||||
const body = document.body;
|
||||
const themeToggle = document.getElementById("theme-toggle");
|
||||
|
||||
const applyStoredTheme = () => {
|
||||
const storedTheme = localStorage.getItem("pref-theme") || sessionStorage.getItem("theme") || "dark";
|
||||
if (storedTheme === "light") {
|
||||
body.classList.add("light");
|
||||
sessionStorage.setItem("theme", "light");
|
||||
localStorage.setItem("pref-theme", "light");
|
||||
return;
|
||||
}
|
||||
|
||||
body.classList.remove("light");
|
||||
sessionStorage.setItem("theme", "dark");
|
||||
localStorage.setItem("pref-theme", "dark");
|
||||
};
|
||||
|
||||
applyStoredTheme();
|
||||
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener("click", () => {
|
||||
const isLight = body.classList.toggle("light");
|
||||
const nextTheme = isLight ? "light" : "dark";
|
||||
sessionStorage.setItem("theme", nextTheme);
|
||||
localStorage.setItem("pref-theme", nextTheme);
|
||||
});
|
||||
}
|
||||
|
||||
body.classList.remove("js-stop-transition");
|
||||
})();
|
||||
Reference in new issue
Block a user