feat: futuristic theme ladder + stacked landing banner

Add three opt-in dark themes (BooCode+, BooCode Classic, BooCode
Override) plus an in-place Ember polish, on a class-scoped effects
engine: matrix rain, a neon grid field, and frosted glass, all gated
by a localStorage "Animated background" toggle and prefers-reduced-
motion. Extend the server theme_id whitelist so the new ids persist,
and replace the Home landing wordmark with the stacked mascot +
wordmark banner.
This commit is contained in:
2026-06-03 14:16:59 +00:00
parent 41f93f5d8e
commit f42c673881
21 changed files with 1822 additions and 30 deletions

View File

@@ -0,0 +1,189 @@
import { useEffect, useRef } from 'react';
/* Matrix code-rain canvas — faithful port of /opt/boolab MatrixRain.jsx.
Amber head (#fbbf24) → orange (#f97316) → deep-rust (#7a3d14) trail, COL_WIDTH
14, throttled to ~24fps, dpr capped at 2, visibilitychange-paused, and
composited with destination-out fade so the canvas stays transparent and
overlays the UI without dimming it. Mounted by ThemeFx only when the
bc-anim-on gate is on (toggle ON *and* not prefers-reduced-motion). */
const CHARSET = (() => {
let s = '';
for (let c = 0x41; c <= 0x5a; c++) s += String.fromCharCode(c); // AZ
for (let c = 0x30; c <= 0x39; c++) s += String.fromCharCode(c); // 09
for (let c = 0xff66; c <= 0xff9d; c++) s += String.fromCharCode(c); // half-width katakana
return s;
})();
const COL_WIDTH = 14;
const TARGET_FPS = 24;
const FRAME_BUDGET = 1000 / TARGET_FPS;
const HEAD_COLOR = '#fbbf24'; // amber
const SECONDARY_COLOR = '#f97316'; // orange
const DEEP_COLOR = '#7a3d14'; // deep rust
function randChar(): string {
return CHARSET.charAt((Math.random() * CHARSET.length) | 0);
}
interface Column {
y: number;
step: number;
}
interface Props {
enabled?: boolean;
density?: number;
speed?: number;
opacity?: number;
}
export function MatrixRain({
enabled = true,
density = 0.35,
speed = 0.7,
opacity = 0.6,
}: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!enabled) return undefined;
const canvas = canvasRef.current;
if (!canvas) return undefined;
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) return undefined;
let width = 0;
let height = 0;
let columns = 0;
let cols: Column[] = [];
let rafId: number | null = null;
let lastTime = performance.now();
let resizeTimer: number | null = null;
const setupCanvas = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
// Measure the element's own CSS box (width:100% + fixed inset:0) rather
// than window.innerWidth — innerWidth includes the scrollbar gutter and
// would over-size the bitmap on scrollbar-present layouts.
width = canvas.clientWidth || window.innerWidth;
height = canvas.clientHeight || window.innerHeight;
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.font = `${COL_WIDTH}px monospace`;
ctx.textBaseline = 'top';
columns = Math.max(1, Math.floor((width / COL_WIDTH) * density));
cols = new Array(columns);
const rowsApprox = Math.ceil(height / COL_WIDTH) + 2;
for (let i = 0; i < columns; i++) {
cols[i] = {
y: -Math.floor(Math.random() * rowsApprox),
step: 0.5 + Math.random() * 0.8,
};
}
// Keep the canvas transparent so it overlays the UI without dimming it.
ctx.clearRect(0, 0, width, height);
};
const draw = () => {
// Fade older chars toward transparent (destination-out) instead of
// filling with a solid trail colour — keeps the canvas alpha-composited.
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = 'rgba(0, 0, 0, 0.08)';
ctx.fillRect(0, 0, width, height);
ctx.globalCompositeOperation = 'source-over';
const effSpeed = Math.max(0.1, speed);
const colPitch = columns > 0 ? width / columns : COL_WIDTH;
for (let i = 0; i < columns; i++) {
const c = cols[i];
if (!c) continue;
const x = Math.floor(i * colPitch);
const yPx = Math.floor(c.y) * COL_WIDTH;
if (c.y >= 0) {
ctx.fillStyle = HEAD_COLOR;
ctx.fillText(randChar(), x, yPx);
if (c.y >= 1) {
ctx.fillStyle = SECONDARY_COLOR;
ctx.fillText(randChar(), x, yPx - COL_WIDTH);
}
if (c.y >= 2) {
ctx.fillStyle = DEEP_COLOR;
ctx.fillText(randChar(), x, yPx - COL_WIDTH * 2);
}
}
c.y += c.step * effSpeed;
const passedBottom = c.y * COL_WIDTH > height;
if (passedBottom && (Math.random() < 0.025 || c.y * COL_WIDTH > height + COL_WIDTH * 20)) {
c.y = -1 - Math.floor(Math.random() * 8);
c.step = 0.5 + Math.random() * 0.8;
}
}
};
const loop = (now: number) => {
rafId = window.requestAnimationFrame(loop);
if (document.hidden) return;
const elapsed = now - lastTime;
if (elapsed < FRAME_BUDGET) return;
lastTime = now - (elapsed % FRAME_BUDGET);
draw();
};
const start = () => {
if (rafId != null) return;
lastTime = performance.now();
rafId = window.requestAnimationFrame(loop);
};
const stop = () => {
if (rafId != null) {
window.cancelAnimationFrame(rafId);
rafId = null;
}
};
const onVisibility = () => {
if (document.hidden) stop();
else start();
};
const onResize = () => {
if (resizeTimer) window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(setupCanvas, 150);
};
setupCanvas();
if (!document.hidden) start();
document.addEventListener('visibilitychange', onVisibility);
window.addEventListener('resize', onResize);
return () => {
stop();
if (resizeTimer) window.clearTimeout(resizeTimer);
document.removeEventListener('visibilitychange', onVisibility);
window.removeEventListener('resize', onResize);
};
}, [enabled, density, speed]);
if (!enabled) return null;
return (
<canvas
ref={canvasRef}
aria-hidden="true"
style={{
position: 'fixed',
inset: 0,
width: '100%',
height: '100%',
zIndex: 0,
pointerEvents: 'none',
display: 'block',
opacity,
}}
/>
);
}