The first pass applied terminal decoration to everything, which read as costume rather than design. Strip it back to a few deliberate cues and let an animated backdrop carry the character instead: - Drop the prefix glyphs from links, tags, nav items, badges and headings, and restore plain section names in place of snake_case. - Keep one prompt in the hero, the ~/ on the wordmark, ISO dates and status dots; everything else is ordinary type. - Restore depth: acrylic surfaces over soft radial light, 6px radii, and an accent glow on the primary action and status dots. - Draw a dithered ASCII field behind the page. A fixed per-cell dither value decides which cells are lit so coverage stays even, while slow interfering waves move brightness through it. It idles when the tab is hidden and stays still under prefers-reduced-motion. - Remove the nested panel boxes on the projects page and size the entry page to its article column. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
179 lines
4.6 KiB
JavaScript
179 lines
4.6 KiB
JavaScript
(function() {
|
|
const canvas = document.getElementById("ascii-bg");
|
|
|
|
if (!canvas || !canvas.getContext) {
|
|
return;
|
|
}
|
|
|
|
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;
|
|
|
|
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 time = 0;
|
|
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;
|
|
};
|
|
|
|
const resize = () => {
|
|
const ratio = Math.min(window.devicePixelRatio || 1, 2);
|
|
const width = window.innerWidth;
|
|
const height = window.innerHeight;
|
|
|
|
canvas.width = Math.floor(width * ratio);
|
|
canvas.height = Math.floor(height * ratio);
|
|
canvas.style.width = `${width}px`;
|
|
canvas.style.height = `${height}px`;
|
|
|
|
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
|
context.font = `12px "JetBrains Mono", ui-monospace, monospace`;
|
|
context.textBaseline = "top";
|
|
|
|
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;
|
|
|
|
for (let row = 0; row < rows; row += 1) {
|
|
for (let column = 0; column < columns; column += 1) {
|
|
const level = waveAt(column, row, time);
|
|
|
|
if (dither[row * columns + column] > BASE_DENSITY + level * WAVE_DENSITY) {
|
|
continue;
|
|
}
|
|
|
|
const glyph = CHARS[Math.min(CHARS.length - 1, Math.floor(level * CHARS.length))];
|
|
const color = level > 0.9 ? accentColor : baseColor;
|
|
|
|
if (color !== currentColor) {
|
|
context.fillStyle = color;
|
|
currentColor = color;
|
|
}
|
|
|
|
context.globalAlpha = 0.06 + level * 0.5;
|
|
context.fillText(glyph, column * CELL_WIDTH, row * CELL_HEIGHT);
|
|
}
|
|
}
|
|
|
|
context.globalAlpha = 1;
|
|
};
|
|
|
|
const loop = (timestamp) => {
|
|
rafId = window.requestAnimationFrame(loop);
|
|
|
|
if (timestamp - lastFrame < FRAME_MS) {
|
|
return;
|
|
}
|
|
|
|
lastFrame = timestamp;
|
|
time += 0.021;
|
|
draw();
|
|
};
|
|
|
|
const stop = () => {
|
|
if (rafId) {
|
|
window.cancelAnimationFrame(rafId);
|
|
rafId = 0;
|
|
}
|
|
};
|
|
|
|
const start = () => {
|
|
if (rafId || prefersReducedMotion.matches) {
|
|
return;
|
|
}
|
|
|
|
lastFrame = 0;
|
|
rafId = window.requestAnimationFrame(loop);
|
|
};
|
|
|
|
const reset = () => {
|
|
readThemeColors();
|
|
resize();
|
|
draw();
|
|
};
|
|
|
|
reset();
|
|
start();
|
|
|
|
let resizeTimer = 0;
|
|
window.addEventListener("resize", () => {
|
|
window.clearTimeout(resizeTimer);
|
|
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();
|
|
return;
|
|
}
|
|
|
|
start();
|
|
});
|
|
|
|
prefersReducedMotion.addEventListener("change", () => {
|
|
stop();
|
|
start();
|
|
draw();
|
|
});
|
|
})();
|