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. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
285
apps/web/src/components/fx/NeonField.tsx
Normal file
285
apps/web/src/components/fx/NeonField.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
/* NeonField — the BooCode Override animated background. DISTINCT from the matrix
|
||||
rain glyph engine: a "neon grid horizon". A perspective floor grid recedes to
|
||||
a vanishing point at a high horizon, scrolling toward the viewer; a slow
|
||||
brightness pulse travels up the rows; sparse data-motes drift through the
|
||||
open "sky" above the horizon. Cyan grid, magenta vanishing-point bloom, violet
|
||||
accents — boolab's booops neon palette.
|
||||
|
||||
This canvas is NET-NEW (boolab's neon was CSS, not a canvas), so it is the
|
||||
riskier of the two fields. It is kept deliberately CHEAP and LEGIBLE:
|
||||
- low primitive count: ROWS+COLS strokes + a handful of motes per frame;
|
||||
- NO per-frame shadowBlur (glow comes from a wide+thin double-stroke and the
|
||||
static CSS bloom layer in boocode-override-fx.css);
|
||||
- a full clearRect each frame (a deterministic redraw, no trail buffer);
|
||||
- dpr capped at 2, throttled to ~30fps, paused on visibilitychange;
|
||||
- the canvas is rendered at < 1 opacity so the field's brightest pixel can
|
||||
never lift the effective panel background enough to break AA text.
|
||||
Mounted by ThemeFx only when the bc-anim-on gate is on (toggle ON *and* not
|
||||
prefers-reduced-motion). */
|
||||
|
||||
const TARGET_FPS = 30;
|
||||
const FRAME_BUDGET = 1000 / TARGET_FPS;
|
||||
|
||||
const ROWS = 18; // horizontal floor lines
|
||||
const COLS = 17; // vertical lines (odd → one passes through the vanishing point)
|
||||
const HORIZON_FRAC = 0.42; // horizon height from the top
|
||||
|
||||
const CYAN = '0, 229, 255';
|
||||
const MAGENTA = '255, 45, 120';
|
||||
const VIOLET = '155, 93, 229';
|
||||
|
||||
interface Mote {
|
||||
x: number; // 0..1 of width
|
||||
y: number; // 0..1 of height (above the horizon)
|
||||
vx: number;
|
||||
vy: number;
|
||||
r: number;
|
||||
hue: string;
|
||||
phase: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
enabled?: boolean;
|
||||
/* Global field dimmer. Caps how much the field can lift any panel background,
|
||||
which is the structural AA guarantee — keep ≤ 0.6. */
|
||||
opacity?: number;
|
||||
/* Rows-per-second the grid scrolls toward the viewer. */
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
export function NeonField({ enabled = true, opacity = 0.55, speed = 0.18 }: 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 horizonY = 0;
|
||||
let cx = 0; // vanishing-point x (centre)
|
||||
let motes: Mote[] = [];
|
||||
let scroll = 0; // grid scroll phase, fractional rows
|
||||
let wave = 0; // travelling brightness-pulse phase
|
||||
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;
|
||||
horizonY = Math.round(height * HORIZON_FRAC);
|
||||
cx = width / 2;
|
||||
canvas.width = Math.max(1, Math.floor(width * dpr));
|
||||
canvas.height = Math.max(1, Math.floor(height * dpr));
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
// Sky motes: count scales gently with viewport area, hard-capped low.
|
||||
const count = Math.min(16, Math.max(6, Math.round((width * height) / 90000)));
|
||||
const hues = [MAGENTA, CYAN, VIOLET];
|
||||
motes = new Array(count);
|
||||
for (let i = 0; i < count; i++) {
|
||||
motes[i] = {
|
||||
x: Math.random(),
|
||||
y: Math.random(),
|
||||
vx: (Math.random() - 0.5) * 0.00006,
|
||||
vy: -(0.00004 + Math.random() * 0.00010), // drift upward
|
||||
r: 0.8 + Math.random() * 1.6,
|
||||
hue: hues[i % hues.length] ?? CYAN,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Perspective Y for a floor row at fractional depth d (0 = horizon, 1 =
|
||||
// bottom edge). Quadratic bunches rows toward the horizon for a perspective
|
||||
// feel without trig.
|
||||
const rowY = (d: number): number => horizonY + (height - horizonY) * d * d;
|
||||
|
||||
const drawGrid = () => {
|
||||
const floorH = height - horizonY;
|
||||
|
||||
// ── Vertical lines: fan out from the vanishing point to the bottom edge.
|
||||
// Static (the motion lives in the horizontal rows), so a single thin
|
||||
// low-alpha stroke each. Spread is widened below the bottom so the outer
|
||||
// lines leave the frame at a steep, dramatic angle.
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i < COLS; i++) {
|
||||
const t = COLS > 1 ? i / (COLS - 1) : 0.5; // 0..1
|
||||
const spread = (t - 0.5) * 2; // -1..1
|
||||
const xBottom = cx + spread * width * 0.95;
|
||||
// Dimmer toward the centre line, brighter at the dramatic outer rake.
|
||||
const a = 0.05 + Math.abs(spread) * 0.09;
|
||||
ctx.strokeStyle = `rgba(${CYAN}, ${a})`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, horizonY);
|
||||
ctx.lineTo(xBottom, height);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// ── Horizontal rows: scroll toward the viewer. Each row gets a wide faint
|
||||
// pass (the glow) and a thin bright pass (the core) — a cheap neon bloom
|
||||
// with no shadowBlur. A travelling sine pulse rides up the rows.
|
||||
for (let i = 0; i < ROWS; i++) {
|
||||
let d = (i + scroll) % ROWS / ROWS; // 0..1, wraps as it scrolls
|
||||
if (d <= 0) d += 1 / ROWS;
|
||||
const y = rowY(d);
|
||||
if (y <= horizonY || y > height + 1) continue;
|
||||
|
||||
// Depth fade: near rows (large d) are brighter than far rows.
|
||||
const depth = 0.18 + d * 0.55;
|
||||
// Travelling brightness pulse along the floor.
|
||||
const pulse = 0.5 + 0.5 * Math.sin((d - wave) * Math.PI * 2);
|
||||
const a = Math.min(0.34, depth * (0.62 + pulse * 0.5));
|
||||
// Width of the row segment narrows toward the horizon (perspective).
|
||||
const halfW = width * (0.5 + d * 0.5);
|
||||
|
||||
// Glow pass (wide, faint, violet-shifted).
|
||||
ctx.lineWidth = 2.4;
|
||||
ctx.strokeStyle = `rgba(${VIOLET}, ${a * 0.4})`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx - halfW, y);
|
||||
ctx.lineTo(cx + halfW, y);
|
||||
ctx.stroke();
|
||||
|
||||
// Core pass (thin, bright, cyan).
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = `rgba(${CYAN}, ${a})`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx - halfW, y);
|
||||
ctx.lineTo(cx + halfW, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// ── Horizon line + vanishing-point magenta bloom. The horizon is the
|
||||
// theme's signature glow band; kept low-alpha and wide so it reads as
|
||||
// bloom, not a hard bright bar behind text.
|
||||
const grad = ctx.createLinearGradient(0, horizonY - floorH * 0.06, 0, horizonY + 2);
|
||||
grad.addColorStop(0, `rgba(${MAGENTA}, 0)`);
|
||||
grad.addColorStop(1, `rgba(${MAGENTA}, 0.10)`);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, horizonY - floorH * 0.06, width, floorH * 0.06 + 2);
|
||||
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = `rgba(${MAGENTA}, 0.22)`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, horizonY);
|
||||
ctx.lineTo(width, horizonY);
|
||||
ctx.stroke();
|
||||
|
||||
// Radial magenta pool at the vanishing point.
|
||||
const vp = ctx.createRadialGradient(cx, horizonY, 0, cx, horizonY, width * 0.22);
|
||||
vp.addColorStop(0, `rgba(${MAGENTA}, 0.16)`);
|
||||
vp.addColorStop(1, `rgba(${MAGENTA}, 0)`);
|
||||
ctx.fillStyle = vp;
|
||||
ctx.fillRect(0, Math.max(0, horizonY - width * 0.22), width, width * 0.44);
|
||||
};
|
||||
|
||||
const drawMotes = (dt: number) => {
|
||||
for (let i = 0; i < motes.length; i++) {
|
||||
const m = motes[i];
|
||||
if (!m) continue;
|
||||
m.x += m.vx * dt;
|
||||
m.y += m.vy * dt;
|
||||
m.phase += dt * 0.0014;
|
||||
// Wrap within the sky band (above the horizon).
|
||||
if (m.y < -0.02) {
|
||||
m.y = 1;
|
||||
m.x = Math.random();
|
||||
}
|
||||
if (m.x < -0.02) m.x = 1.02;
|
||||
else if (m.x > 1.02) m.x = -0.02;
|
||||
|
||||
const px = m.x * width;
|
||||
const py = m.y * horizonY; // confine to the sky region
|
||||
const twinkle = 0.45 + 0.55 * (0.5 + 0.5 * Math.sin(m.phase));
|
||||
const a = Math.min(0.5, twinkle * 0.5);
|
||||
const g = ctx.createRadialGradient(px, py, 0, px, py, m.r * 3.2);
|
||||
g.addColorStop(0, `rgba(${m.hue}, ${a})`);
|
||||
g.addColorStop(1, `rgba(${m.hue}, 0)`);
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, m.r * 3.2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
};
|
||||
|
||||
const draw = (dt: number) => {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
const effSpeed = Math.max(0, speed);
|
||||
scroll = (scroll + (dt / 1000) * effSpeed * ROWS) % ROWS;
|
||||
wave = (wave + dt / 1000 / 6) % 1; // one pulse sweep every ~6s
|
||||
drawGrid();
|
||||
drawMotes(dt);
|
||||
};
|
||||
|
||||
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(Math.min(elapsed, 100)); // clamp dt so a backgrounded tab can't jump
|
||||
};
|
||||
|
||||
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, 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