diff --git a/apps/server/src/routes/settings.ts b/apps/server/src/routes/settings.ts index d9ced66..5b6535d 100644 --- a/apps/server/src/routes/settings.ts +++ b/apps/server/src/routes/settings.ts @@ -45,6 +45,10 @@ const THEME_IDS = [ 'cobalt', 'midnight-sapphire', 'ember', + // futuristic ladder (opt-in) — kept in sync with apps/web/src/lib/theme.ts THEMES + 'boocode-plus', + 'boocode-classic', + 'boocode-override', ] as const; const THEME_MODES = ['dark', 'light', 'system'] as const; diff --git a/apps/web/index.html b/apps/web/index.html index 054b46b..76aea83 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -7,23 +7,29 @@ diff --git a/apps/web/package.json b/apps/web/package.json index d668ee4..2e467a1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,6 +13,7 @@ "@boocode/contracts": "workspace:*", "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@fontsource/orbitron": "^5.2.0", "@xterm/addon-fit": "0.10.0", "@xterm/addon-search": "^0.15.0", "@xterm/addon-web-links": "0.11.0", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 9e15dc8..6621197 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -13,6 +13,7 @@ import { useTheme } from '@/lib/theme'; import { SidebarDrawerProvider, useSidebarDrawer } from '@/hooks/useSidebarDrawer'; import { RightRailDrawerProvider, useRightRailDrawer } from '@/hooks/useRightRailDrawer'; import { useViewport } from '@/hooks/useViewport'; +import { ThemeFx } from '@/components/fx/ThemeFx'; function SessionRightRail() { const { id } = useParams<{ id: string }>(); @@ -73,24 +74,32 @@ function AppShell() { // descendant — including the terminal pane — measures itself against a // height that extends behind the URL bar, and xterm allocates extra rows // that scroll out of reach on iPhone. + // + // ThemeFx: canvas-based animated backgrounds (Classic rain, Override neon + // field) rendered at z-0 behind the content wrapper. ThemeFx also manages + // the bc-anim-on gate class on . Content wrapper at z-10 ensures all + // UI sits above any fixed canvas regardless of theme. return ( -
- - -
+ <> + +
+ + +
+ + } /> + } /> + } /> + } /> + +
+ - } /> - } /> - } /> - } /> + } /> -
- - - } /> - - -
+ + + ); } diff --git a/apps/web/src/components/ThemePicker.tsx b/apps/web/src/components/ThemePicker.tsx index 40d7056..0a977e9 100644 --- a/apps/web/src/components/ThemePicker.tsx +++ b/apps/web/src/components/ThemePicker.tsx @@ -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() { +
+
+

Animated background

+ +
+

+ Disables canvas animations and CSS effects for all themes. Persisted locally. +

+
+

Theme

@@ -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 ( Light only
)} + {isDarkOnly && ( +
Dark only
+ )} ); })} diff --git a/apps/web/src/components/fx/MatrixRain.tsx b/apps/web/src/components/fx/MatrixRain.tsx new file mode 100644 index 0000000..7bb2f6e --- /dev/null +++ b/apps/web/src/components/fx/MatrixRain.tsx @@ -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(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 ( +