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:
189
apps/web/src/components/fx/MatrixRain.tsx
Normal file
189
apps/web/src/components/fx/MatrixRain.tsx
Normal 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); // A–Z
|
||||
for (let c = 0x30; c <= 0x39; c++) s += String.fromCharCode(c); // 0–9
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user