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

@@ -5,6 +5,7 @@ import { Card } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { THEMES, setTheme, useTheme, type ThemeId, type ThemeMode } from '@/lib/theme';
import { useAnimBg, setAnimBg } from '@/lib/anim';
import { cn } from '@/lib/utils';
// v1.9: lifted out of pages/Settings.tsx so the SettingsPane Theme tab and
@@ -19,6 +20,7 @@ const MODES: { value: ThemeMode; label: string; hint: string }[] = [
export function ThemePicker() {
const { id: currentId, mode: currentMode } = useTheme();
const animOn = useAnimBg();
// Track the most recent in-flight pick so the picker can show a subtle
// "applying…" state on the targeted card while the PATCH is in flight.
const [pending, setPending] = useState<
@@ -70,6 +72,33 @@ export function ThemePicker() {
</RadioGroup>
</section>
<section className="space-y-3">
<div className="flex items-center justify-between gap-4">
<h2 className="text-sm font-medium">Animated background</h2>
<button
type="button"
role="switch"
aria-checked={animOn}
onClick={() => setAnimBg(!animOn)}
className={cn(
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent',
'transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
animOn ? 'bg-primary' : 'bg-input',
)}
>
<span
className={cn(
'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform duration-200',
animOn ? 'translate-x-4' : 'translate-x-0',
)}
/>
</button>
</div>
<p className="text-xs text-muted-foreground">
Disables canvas animations and CSS effects for all themes. Persisted locally.
</p>
</section>
<section className="space-y-3">
<h2 className="text-sm font-medium">Theme</h2>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
@@ -77,6 +106,7 @@ export function ThemePicker() {
const isActive = t.id === currentId;
const isPending = pending?.kind === 'theme' && pending.id === t.id;
const isLightOnly = !t.supportsDark;
const isDarkOnly = !t.supportsLight;
return (
<Card
key={t.id}
@@ -112,6 +142,9 @@ export function ThemePicker() {
{isLightOnly && (
<div className="mt-2 text-xs text-muted-foreground italic">Light only</div>
)}
{isDarkOnly && (
<div className="mt-2 text-xs text-muted-foreground italic">Dark only</div>
)}
</Card>
);
})}

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,
}}
/>
);
}

View 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,
}}
/>
);
}

View File

@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
import { useTheme } from '@/lib/theme';
import { useAnimBg } from '@/lib/anim';
import { MatrixRain } from './MatrixRain';
import { NeonField } from './NeonField';
// Derives the animation gate: toggle ON *and* not prefers-reduced-motion.
// When false, both the canvas mount and the bc-anim-on html class are removed,
// disabling all CSS animations (scanline, caret, glitch) that scope under it.
function useAnimGate(): boolean {
const animOn = useAnimBg();
const [reducedMotion, setReducedMotion] = useState(() => {
if (typeof window === 'undefined') return false;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
});
useEffect(() => {
if (typeof window === 'undefined') return;
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
const onChange = (e: MediaQueryListEvent) => setReducedMotion(e.matches);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, []);
return animOn && !reducedMotion;
}
// Mounted once in AppShell. Manages:
// 1. The bc-anim-on class on <html> (gate for all theme animation CSS).
// 2. The per-theme canvas component (MatrixRain / NeonField / null).
export function ThemeFx() {
const { id } = useTheme();
const gateOn = useAnimGate();
// Sync bc-anim-on class. Effect runs whenever gateOn changes; cleanup
// removes the class so a stale gate never persists across unmount.
useEffect(() => {
if (gateOn) {
document.documentElement.classList.add('bc-anim-on');
} else {
document.documentElement.classList.remove('bc-anim-on');
}
return () => {
document.documentElement.classList.remove('bc-anim-on');
};
}, [gateOn]);
if (id === 'boocode-classic') return <MatrixRain enabled={gateOn} />;
if (id === 'boocode-override') return <NeonField enabled={gateOn} />;
return null;
}