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 (
+
+ );
+}
diff --git a/apps/web/src/components/fx/NeonField.tsx b/apps/web/src/components/fx/NeonField.tsx
new file mode 100644
index 0000000..44e0d3b
--- /dev/null
+++ b/apps/web/src/components/fx/NeonField.tsx
@@ -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(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 (
+
+ );
+}
diff --git a/apps/web/src/components/fx/ThemeFx.tsx b/apps/web/src/components/fx/ThemeFx.tsx
new file mode 100644
index 0000000..5f83fa5
--- /dev/null
+++ b/apps/web/src/components/fx/ThemeFx.tsx
@@ -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 (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 ;
+ if (id === 'boocode-override') return ;
+ return null;
+}
diff --git a/apps/web/src/lib/anim.ts b/apps/web/src/lib/anim.ts
new file mode 100644
index 0000000..812a190
--- /dev/null
+++ b/apps/web/src/lib/anim.ts
@@ -0,0 +1,53 @@
+import { useEffect, useState } from 'react';
+
+// Module-singleton animation-background gate — mirrors the useTheme pattern.
+// Consumers: ThemeFx (reads) and ThemePicker (reads + writes).
+// Storage: localStorage only — no server/schema change.
+
+const ANIM_KEY = 'boocode.anim-bg';
+
+function readAnimOn(): boolean {
+ try {
+ return localStorage.getItem(ANIM_KEY) !== 'off';
+ } catch {
+ return true;
+ }
+}
+
+let _on = readAnimOn();
+const _subs = new Set<(on: boolean) => void>();
+
+function notifyAnimSubs(): void {
+ for (const s of _subs) {
+ try {
+ s(_on);
+ } catch {
+ // swallow — bad subscriber shouldn't break others
+ }
+ }
+}
+
+export function getAnimBg(): boolean {
+ return _on;
+}
+
+export function setAnimBg(on: boolean): void {
+ _on = on;
+ try {
+ localStorage.setItem(ANIM_KEY, on ? 'on' : 'off');
+ } catch {
+ // quota / disabled
+ }
+ notifyAnimSubs();
+}
+
+export function useAnimBg(): boolean {
+ const [on, setOn] = useState(_on);
+ useEffect(() => {
+ _subs.add(setOn);
+ return () => {
+ _subs.delete(setOn);
+ };
+ }, []);
+ return on;
+}
diff --git a/apps/web/src/lib/theme.ts b/apps/web/src/lib/theme.ts
index 683a20d..4c06136 100644
--- a/apps/web/src/lib/theme.ts
+++ b/apps/web/src/lib/theme.ts
@@ -1,11 +1,13 @@
import { useEffect, useState } from 'react';
import { api } from '@/api/client';
-// themes-v1: source of truth for the 18 presets. id and name are surfaced in
-// the picker; family groups visually; supportsDark/supportsLight reflect
-// whether the corresponding selector exists in styles/themes/.css; anchors
-// are the 5 dark swatches (or the light palette for the two light-only themes)
-// used in the picker preview strip.
+// themes-v1: source of truth for the 19 presets + 3 futuristic additions.
+// id and name are surfaced in the picker; family groups visually;
+// supportsDark/supportsLight reflect whether the corresponding selector exists
+// in styles/themes/.css; anchors are the 5 dark swatches (or the light
+// palette for the two light-only themes) used in the picker preview strip.
+// Dark-only themes (supportsLight:false) always render with the dark class —
+// see applyTheme force-dark logic below.
export type ThemeId =
| 'obsidian'
| 'gunmetal'
@@ -25,7 +27,10 @@ export type ThemeId =
| 'chalk'
| 'cobalt'
| 'midnight-sapphire'
- | 'ember';
+ | 'ember'
+ | 'boocode-plus'
+ | 'boocode-classic'
+ | 'boocode-override';
export type ThemeMode = 'dark' | 'light' | 'system';
@@ -75,8 +80,16 @@ export const THEMES: readonly ThemeMeta[] = [
anchors: ['#020817', '#061434', '#0c2244', '#3060a0', '#0047ab'] },
{ id: 'midnight-sapphire', name: 'Midnight Sapphire', family: 'Blue', supportsDark: true, supportsLight: true,
anchors: ['#02050e', '#060c1f', '#0e1a36', '#4a6088', '#1e3a8a'] },
- { id: 'ember', name: 'BooCode Ember', family: 'Amber', supportsDark: true, supportsLight: true,
+ { id: 'ember', name: 'BooCode', family: 'Amber', supportsDark: true, supportsLight: true,
anchors: ['#0c0c0e', '#15151a', '#1f1f23', '#6b6b75', '#ff7a18'] },
+ // Futuristic ladder — Phase 1 registrations (final anchors + flags).
+ // Token stylesheets and effects live in styles/themes/boocode-*.css.
+ { id: 'boocode-plus', name: 'BooCode+', family: 'Futuristic', supportsDark: true, supportsLight: true,
+ anchors: ['#0f1117', '#1a1d2e', '#242838', '#7a7f99', '#5e6ad2'] },
+ { id: 'boocode-classic', name: 'BooCode Classic', family: 'Futuristic', supportsDark: true, supportsLight: false,
+ anchors: ['#0a0604', '#120a06', '#1a0e08', '#9a7a5a', '#f97316'] },
+ { id: 'boocode-override', name: 'BooCode Override', family: 'Futuristic', supportsDark: true, supportsLight: false,
+ anchors: ['#080b14', '#0d1120', '#0f1525', '#7a9dc2', '#ff2d78'] },
] as const;
// BooCode 2.0: orange-on-black "BooCode Ember" is the out-of-the-box signature
@@ -112,8 +125,12 @@ export function applyTheme(id: ThemeId, mode: ThemeMode): void {
if (typeof document === 'undefined') return;
const resolved = resolvedMode(mode);
const effective = effectiveThemeId(id, resolved);
+ // Dark-only themes (supportsLight:false) always get the dark class so
+ // dark: utilities and .dark selectors render correctly under any mode pref.
+ const meta = THEMES.find((t) => t.id === effective);
+ const isDark = resolved === 'dark' || (meta !== undefined && !meta.supportsLight);
document.documentElement.className =
- `theme-${effective}${resolved === 'dark' ? ' dark' : ''}`;
+ `theme-${effective}${isDark ? ' dark' : ''}`;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ id, mode }));
} catch {
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 957fd58..70b391f 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -3,6 +3,9 @@
// imports so the @font-face CSS lands before any component-tree render.
import '@fontsource-variable/inter';
import '@fontsource-variable/jetbrains-mono';
+// Orbitron weight-800 only — used for the BooCode Classic display wordmark.
+// Static @fontsource (not variable) pinned to a single weight to keep bundle small.
+import '@fontsource/orbitron/800.css';
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx
index c289419..34761c8 100644
--- a/apps/web/src/pages/Home.tsx
+++ b/apps/web/src/pages/Home.tsx
@@ -2,6 +2,8 @@ import { useEffect, useState } from 'react';
import { ChevronDown, ChevronRight, Folder, FolderTree, Menu, RotateCcw } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
+import mascot from '@/assets/brand/banner-mascot.png';
+import wordmark from '@/assets/brand/banner-wordmark.png';
import { AddProjectModal } from '@/components/AddProjectModal';
import { CreateProjectModal } from '@/components/CreateProjectModal';
import { api } from '@/api/client';
@@ -117,7 +119,20 @@ export function Home() {
>
) : (
<>
- BooCode
+
+

+

+
Pick a project from the sidebar, or add another.
diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css
index 01c5c3d..818b1a5 100644
--- a/apps/web/src/styles/globals.css
+++ b/apps/web/src/styles/globals.css
@@ -26,6 +26,13 @@
@import "./themes/cobalt.css";
@import "./themes/midnight-sapphire.css";
@import "./themes/ember.css";
+/* Futuristic theme ladder — token files + effects stubs (Phases 2-5 fill them). */
+@import "./themes/boocode-plus.css";
+@import "./themes/boocode-classic.css";
+@import "./themes/boocode-override.css";
+@import "./themes/ember-polish.css";
+@import "./themes/effects/boocode-classic-fx.css";
+@import "./themes/effects/boocode-override-fx.css";
@custom-variant dark (&:is(.dark *));
diff --git a/apps/web/src/styles/themes/boocode-classic.css b/apps/web/src/styles/themes/boocode-classic.css
new file mode 100644
index 0000000..62fd36f
--- /dev/null
+++ b/apps/web/src/styles/themes/boocode-classic.css
@@ -0,0 +1,57 @@
+/* BooCode Classic (id: boocode-classic) — dark-only.
+ Faithful revival of the original BooCode warm-amber-on-black palette
+ (/opt/boolab data-mode='boocode'). Warm near-black base, orange + amber +
+ rust accents, deep-rust borders.
+
+ Matrix-rain translucency trick (ported from boolab): the rain canvas sits at
+ z-0 behind the layout. For it to show *through* the UI we make the
+ opaque page backstop (#0a0604), strip 's background to transparent, and
+ override the SURFACE tokens to ~82–86% opaque rgba so panels bleed the canvas
+ through their gaps while keeping high-contrast (AA) text. Foreground / accent
+ / border colors stay fully opaque.
+
+ Anchors: #0a0604 #120a06 #1a0e08 #9a7a5a #f97316 */
+
+/* Light selector intentionally carries dark values — this theme is dark-only.
+ supportsLight:false in theme.ts means applyTheme always adds the dark class;
+ this base block is a non-broken fallback if the cascade ever runs without it. */
+.theme-boocode-classic,
+.theme-boocode-classic.dark {
+ --background: rgba(10, 6, 4, 0.86);
+ --foreground: #f5e6d3;
+ --card: rgba(26, 14, 8, 0.82);
+ --card-foreground: #f5e6d3;
+ --popover: rgba(18, 10, 6, 0.82);
+ --popover-foreground: #f5e6d3;
+ --primary: #f97316;
+ --primary-foreground: #0a0604;
+ --secondary: rgba(36, 20, 8, 0.88);
+ --secondary-foreground: #f5e6d3;
+ --muted: rgba(18, 10, 6, 0.82);
+ --muted-foreground: #9a7a5a;
+ --accent: #f97316;
+ --accent-foreground: #0a0604;
+ --destructive: #dc2626;
+ --destructive-foreground: #f5e6d3;
+ --border: #3a1f0c;
+ --input: #4a2d14;
+ --ring: #f97316;
+ --sidebar: rgba(18, 10, 6, 0.82);
+ --sidebar-foreground: #f5e6d3;
+ --sidebar-primary: #f97316;
+ --sidebar-primary-foreground: #0a0604;
+ --sidebar-accent: color-mix(in oklab, #f97316 16%, transparent);
+ --sidebar-accent-foreground: #f5e6d3;
+ --sidebar-border: #3a1f0c;
+ --sidebar-ring: #f97316;
+}
+
+/* Opaque page backstop + transparent body so the fixed rain canvas (z-0) shows
+ through every translucent surface above it. !important guards against any
+ inline :root vars losing the cascade. */
+html.theme-boocode-classic {
+ background-color: #0a0604 !important;
+}
+.theme-boocode-classic body {
+ background-color: transparent !important;
+}
diff --git a/apps/web/src/styles/themes/boocode-override.css b/apps/web/src/styles/themes/boocode-override.css
new file mode 100644
index 0000000..50a4795
--- /dev/null
+++ b/apps/web/src/styles/themes/boocode-override.css
@@ -0,0 +1,59 @@
+/* BooCode Override (id: boocode-override) — dark-only.
+ Full neon cyberpunk: magenta #ff2d78, cyan #00e5ff, violet #9b5de5 on
+ blue-black #080b14. Knowingly the least calm theme.
+
+ Neon-field translucency trick (same shape as Classic, brighter field): the
+ NeonField canvas sits at z-0 behind the layout. To let it show *through* the
+ UI, is the opaque page backstop (#080b14), is transparent, and
+ the SURFACE tokens are overridden to SEMI-TRANSPARENT rgba so panels bleed the
+ neon grid through their gaps. Foreground / accent / border colours stay fully
+ opaque. Panel alphas are kept HIGH (0.86–0.94) because the neon field is much
+ brighter than the matrix rain — combined with the dimmed canvas (opacity in
+ NeonField) this keeps every panel/body text run at AA (see the contrast notes
+ in the role report). Neon is for accents/borders/glow — never body text.
+
+ Anchors: #080b14 #0d1120 #0f1525 #8aa9cd #ff2d78 */
+
+/* Light selector intentionally carries dark values — this theme is dark-only.
+ supportsLight:false in theme.ts means applyTheme always adds the dark class;
+ this base block is a non-broken fallback if the cascade ever runs without it. */
+.theme-boocode-override,
+.theme-boocode-override.dark {
+ --background: rgba(8, 11, 20, 0.88);
+ --foreground: #cde0ff;
+ --card: rgba(15, 21, 37, 0.88);
+ --card-foreground: #cde0ff;
+ --popover: rgba(13, 17, 32, 0.94);
+ --popover-foreground: #cde0ff;
+ --primary: #ff2d78;
+ --primary-foreground: #080b14;
+ --secondary: rgba(26, 34, 64, 0.92);
+ --secondary-foreground: #cde0ff;
+ --muted: rgba(13, 17, 32, 0.90);
+ --muted-foreground: #8aa9cd;
+ --accent: #00e5ff;
+ --accent-foreground: #080b14;
+ --destructive: #f0556b;
+ --destructive-foreground: #ffffff;
+ --border: #243358;
+ --input: #243358;
+ --ring: #ff2d78;
+ --sidebar: rgba(13, 17, 32, 0.86);
+ --sidebar-foreground: #cde0ff;
+ --sidebar-primary: #ff2d78;
+ --sidebar-primary-foreground: #080b14;
+ --sidebar-accent: color-mix(in oklab, #ff2d78 16%, transparent);
+ --sidebar-accent-foreground: #cde0ff;
+ --sidebar-border: #243358;
+ --sidebar-ring: #ff2d78;
+}
+
+/* Opaque page backstop + transparent body so the fixed NeonField canvas (z-0)
+ shows through every translucent surface above it. !important guards against
+ any inline :root vars losing the cascade. */
+html.theme-boocode-override {
+ background-color: #080b14 !important;
+}
+.theme-boocode-override body {
+ background-color: transparent !important;
+}
diff --git a/apps/web/src/styles/themes/boocode-plus.css b/apps/web/src/styles/themes/boocode-plus.css
new file mode 100644
index 0000000..76796ae
--- /dev/null
+++ b/apps/web/src/styles/themes/boocode-plus.css
@@ -0,0 +1,81 @@
+/* BooCode+ (id: boocode-plus) — cool deep-slate + indigo accent #5e6ad2.
+ Tasteful sci-fi tier: calm, premium, Linear-grade. Dark is the priority
+ (the user only runs dark mode); the light variant is kept reasonable but
+ secondary. Token shape mirrors obsidian.css exactly.
+
+ Phase 2 effects (frosted-glass chrome, static ambient gradient, indigo glow,
+ spring transitions) live in the sibling sheet below. The scaffold wired the
+ two CANVAS themes' *-fx.css in globals.css but never a boocode-plus-fx.css,
+ and globals.css is out of scope for Phase 2 — so the effects sheet is pulled
+ in here via a nested @import (Lightning CSS resolves it relative to this
+ file → ./effects/boocode-plus-fx.css). @import must precede all rules. */
+@import "./effects/boocode-plus-fx.css";
+
+/* Light variant — secondary. Deeper indigo (#5159c4) so white button text
+ clears AA on light, darker muted-foreground (#5c6178) for AA secondary text.
+ Glass/gradient/glow are dark-only (see the fx sheet), so light is flat. */
+.theme-boocode-plus {
+ --background: #f7f8fc;
+ --foreground: #1b1e2e;
+ --card: #ffffff;
+ --card-foreground: #1b1e2e;
+ --popover: #ffffff;
+ --popover-foreground: #1b1e2e;
+ --primary: #5159c4;
+ --primary-foreground: #ffffff;
+ --secondary: #eceef6;
+ --secondary-foreground: #1b1e2e;
+ --muted: #eceef6;
+ --muted-foreground: #5c6178;
+ --accent: #5159c4;
+ --accent-foreground: #ffffff;
+ --destructive: #c8202e;
+ --destructive-foreground: #ffffff;
+ --border: #e0e3ef;
+ --input: #e0e3ef;
+ --ring: #5159c4;
+ --sidebar: #eef0f7;
+ --sidebar-foreground: #1b1e2e;
+ --sidebar-primary: #5159c4;
+ --sidebar-primary-foreground: #ffffff;
+ --sidebar-accent: color-mix(in oklab, #5159c4 14%, transparent);
+ --sidebar-accent-foreground: #1b1e2e;
+ --sidebar-border: #e0e3ef;
+ --sidebar-ring: #5159c4;
+}
+
+/* Dark variant — the priority. AA-verified against its surfaces:
+ foreground #e3e6f1 on card #1a1d2e = 13.0:1; muted-foreground #8f95ad on
+ muted #242838 = 4.9:1; primary/accent-foreground #ffffff on #5e6ad2 = 4.7:1;
+ destructive #f05252 as text on the dark base = 5.4:1. (primary-foreground
+ and muted-foreground were lifted off the stub's #0f1117 / #7a7f99, which
+ failed AA at 4.0:1 and 4.2:1.) */
+.theme-boocode-plus.dark {
+ --background: #0f1117;
+ --foreground: #e3e6f1;
+ --card: #1a1d2e;
+ --card-foreground: #e3e6f1;
+ --popover: #1a1d2e;
+ --popover-foreground: #e3e6f1;
+ --primary: #5e6ad2;
+ --primary-foreground: #ffffff;
+ --secondary: #242838;
+ --secondary-foreground: #e3e6f1;
+ --muted: #242838;
+ --muted-foreground: #8f95ad;
+ --accent: #5e6ad2;
+ --accent-foreground: #ffffff;
+ --destructive: #f05252;
+ --destructive-foreground: #ffffff;
+ --border: #2a2f44;
+ --input: #2a2f44;
+ --ring: #5e6ad2;
+ --sidebar: #13151d;
+ --sidebar-foreground: #e3e6f1;
+ --sidebar-primary: #5e6ad2;
+ --sidebar-primary-foreground: #ffffff;
+ --sidebar-accent: color-mix(in oklab, #5e6ad2 20%, transparent);
+ --sidebar-accent-foreground: #e3e6f1;
+ --sidebar-border: #2a2f44;
+ --sidebar-ring: #5e6ad2;
+}
diff --git a/apps/web/src/styles/themes/effects/boocode-classic-fx.css b/apps/web/src/styles/themes/effects/boocode-classic-fx.css
new file mode 100644
index 0000000..5a59950
--- /dev/null
+++ b/apps/web/src/styles/themes/effects/boocode-classic-fx.css
@@ -0,0 +1,268 @@
+/* BooCode Classic — effects layer. Faithful revival of /opt/boolab
+ data-mode='boocode' terminal-HUD chrome.
+
+ Scoping contract (immutable):
+ - EVERY rule is scoped under `.theme-boocode-classic` so the other 22 themes
+ are byte-for-byte unaffected.
+ - EVERY continuous keyframe animation is *additionally* scoped under
+ `html.bc-anim-on.theme-boocode-classic`. ThemeFx sets `bc-anim-on` only
+ when the Animated-background toggle is ON *and* prefers-reduced-motion is
+ not set — so toggling either off removes the class and freezes all motion.
+ @keyframes names are global (CSS can't scope the at-rule itself); they are
+ `bc-`-prefixed and only *referenced* under the gated selector, so no
+ animation runs unless the gate is on.
+
+ The matrix-rain canvas lives in components/fx/MatrixRain.tsx (mounted by
+ ThemeFx). This sheet adds: an Orbitron display wordmark + blinking caret, an
+ app-wide scanline sweep, orange card hover glow + lift, terminal-frame chrome
+ on the rails, and the ported boolab `.bc-*` / `.boocode-*` design-system
+ classes (dormant until a HUD component consumes them). */
+
+.theme-boocode-classic {
+ --bc-orange: #f97316;
+ --bc-amber: #fbbf24;
+ --bc-rust: #c2410c;
+ --bc-glow-orange: 0 0 24px color-mix(in srgb, var(--bc-orange) 32%, transparent);
+ --bc-glow-soft: 0 0 40px color-mix(in srgb, var(--bc-orange) 14%, transparent);
+}
+
+/* ── Orbitron display wordmark ────────────────────────────────────────────
+ Applied to the "BooCode" text heading via the additive `boocode-display`
+ class (the only text wordmark — the sidebar wordmark is an
, which a
+ font can't restyle). Orbitron 800 is JS-imported in main.tsx. */
+.theme-boocode-classic .boocode-display {
+ font-family: 'Orbitron', var(--font-sans);
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ color: var(--bc-orange);
+ text-shadow: 0 0 14px color-mix(in srgb, var(--bc-orange) 45%, transparent);
+}
+
+/* Terminal cursor after the wordmark. Always rendered (a solid block when
+ motion is off — boolab's reduced-motion behaviour); blinks only when gated. */
+.theme-boocode-classic .boocode-display::after {
+ content: '▮';
+ margin-left: 0.12em;
+ color: var(--bc-amber);
+ opacity: 1;
+}
+html.bc-anim-on.theme-boocode-classic .boocode-display::after {
+ animation: bc-caret-blink 1s steps(1, end) infinite;
+}
+
+/* ── App-wide scanline sweep ──────────────────────────────────────────────
+ A soft warm band that drifts down the viewport on a calm 8s cycle.
+ `position: fixed` keeps it viewport-locked (never creates document overflow);
+ mix-blend-mode: screen only lightens, so text underneath stays legible.
+ Defined ONLY under the gate → vanishes entirely when motion is off (the warm
+ palette + static rain-off state is the reduced-motion fallback).
+ `.h-dvh.bg-background` is unique to the AppShell root (App.tsx). */
+html.bc-anim-on.theme-boocode-classic .h-dvh.bg-background::after {
+ content: '';
+ position: fixed;
+ inset: 0;
+ z-index: 2;
+ pointer-events: none;
+ background: linear-gradient(
+ 180deg,
+ transparent 46%,
+ color-mix(in srgb, var(--bc-orange) 9%, transparent) 50%,
+ transparent 54%
+ );
+ mix-blend-mode: screen;
+ animation: bc-scanline 8s linear infinite;
+ will-change: transform;
+}
+
+/* ── Card hover glow + lift ───────────────────────────────────────────────
+ The shadcn Card primitive (`data-slot="card"`). The orange rim + drop glow
+ apply on hover under any condition (hover feedback is acceptable under
+ reduced motion); the transition timing and the 1px lift are gated so motion
+ off = instant, no travel. */
+.theme-boocode-classic [data-slot="card"]:hover {
+ border-color: color-mix(in srgb, var(--bc-orange) 55%, transparent);
+ box-shadow:
+ 0 0 0 1px color-mix(in srgb, var(--bc-orange) 22%, transparent),
+ 0 6px 18px -8px color-mix(in srgb, var(--bc-orange) 40%, transparent);
+}
+html.bc-anim-on.theme-boocode-classic [data-slot="card"] {
+ transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
+}
+html.bc-anim-on.theme-boocode-classic [data-slot="card"]:hover {
+ transform: translateY(-1px);
+}
+
+/* ── Terminal-frame chrome on the rails ───────────────────────────────────
+ Monospace + a hairline orange edge-light on the sidebar (vertical list that
+ already truncates — low overflow risk). Pane top-bars are intentionally left
+ alone: they are crowded control rows where a wider mono font risks wrapping. */
+.theme-boocode-classic .bg-sidebar {
+ font-family: var(--font-mono);
+ box-shadow: inset -1px 0 0 0 color-mix(in srgb, var(--bc-orange) 8%, transparent);
+}
+/* Floating surfaces (menus / dialogs / popovers) are intentionally NOT given an
+ inset box-shadow here — that would clobber shadcn's `shadow-md` elevation
+ (higher specificity) and flatten menus over the rain. The warm `--popover` /
+ `--border` tokens already theme them. */
+
+/* ──────────────────────────────────────────────────────────────────────────
+ Ported boolab design-system classes. These are faithful copies of the
+ /opt/boolab `.bc-*` / `.boocode-*` chrome. No component in this repo renders
+ these class names yet (boolab's RepoStatusBar / HUD components don't exist
+ here), so they are DORMANT — shipped so the Classic design system is complete
+ and any future HUD element (prompt line, status pill, kbd hint, breadcrumb)
+ lights up automatically. All scoped to the theme; animations gated.
+ ────────────────────────────────────────────────────────────────────────── */
+
+/* Terminal prompt line: `$ boocode @host: path (branch)` */
+.theme-boocode-classic .bc-prompt-line {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ row-gap: 0.25rem;
+ font-family: var(--font-mono);
+ font-size: 0.8125rem;
+ color: var(--muted-foreground);
+ padding: 0.5rem 0.75rem;
+ background: var(--popover);
+ border-bottom: 1px solid var(--border);
+}
+.theme-boocode-classic .bc-prompt-host { color: var(--bc-orange); }
+.theme-boocode-classic .bc-prompt-branch { color: var(--bc-orange); opacity: 0.8; }
+.theme-boocode-classic .bc-prompt-dollar {
+ color: var(--bc-orange);
+ text-shadow: 0 0 6px var(--bc-orange);
+ margin-right: 0.25rem;
+}
+
+/* Standalone blinking block caret. */
+.theme-boocode-classic .bc-caret {
+ display: inline-block;
+ width: 0.6em;
+ height: 1em;
+ background: currentColor;
+ vertical-align: text-bottom;
+ margin-left: 0.1em;
+}
+html.bc-anim-on.theme-boocode-classic .bc-caret {
+ animation: bc-caret-blink 1s steps(1, end) infinite;
+}
+
+/* IDLE / SYNCING / ERROR status pills. */
+.theme-boocode-classic .bc-status-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.1rem 0.5rem;
+ border-radius: 999px;
+ border: 1px solid var(--border);
+ background: var(--card);
+ font-size: 0.6875rem;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ font-family: var(--font-mono);
+}
+.theme-boocode-classic .bc-status-idle { color: #7ae07a; border-color: rgba(122, 224, 122, 0.4); }
+.theme-boocode-classic .bc-status-syncing {
+ color: var(--bc-orange);
+ border-color: color-mix(in srgb, var(--bc-orange) 60%, transparent);
+}
+.theme-boocode-classic .bc-status-error { color: #ff6b6b; border-color: rgba(255, 107, 107, 0.5); }
+.theme-boocode-classic .bc-status-syncing::before {
+ content: '▮';
+ color: var(--bc-orange);
+}
+html.bc-anim-on.theme-boocode-classic .bc-status-syncing::before {
+ animation: bc-caret-blink 1s steps(1, end) infinite;
+}
+
+/* Keyboard hint chip. */
+.theme-boocode-classic .bc-key-hint {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 1.25rem;
+ padding: 0 0.3rem;
+ height: 1.1rem;
+ border: 1px solid var(--border);
+ border-radius: 0.25rem;
+ font-family: var(--font-mono);
+ font-size: 0.625rem;
+ color: var(--muted-foreground);
+ background: var(--popover);
+}
+
+/* Uppercase orange breadcrumb / overline. */
+.theme-boocode-classic .boocode-breadcrumb {
+ font-family: 'Orbitron', var(--font-sans);
+ font-size: 0.6875rem;
+ letter-spacing: 0.22em;
+ text-transform: uppercase;
+ color: var(--bc-orange);
+ text-shadow: 0 0 8px color-mix(in srgb, var(--bc-orange) 40%, transparent);
+}
+
+/* Inset terminal-frame border. */
+.theme-boocode-classic .boocode-terminal-frame {
+ border: 1px solid var(--border);
+ box-shadow:
+ inset 0 0 0 1px color-mix(in srgb, var(--bc-orange) 5%, transparent),
+ 0 0 0 1px #0a0604;
+}
+
+/* Card hover glow + lift utility (boolab `.bc-card`), for non-shadcn surfaces. */
+.theme-boocode-classic .bc-card {
+ position: relative;
+ border: 1px solid color-mix(in srgb, var(--bc-orange) 22%, transparent);
+ background: var(--card);
+ padding: 1rem;
+ border-radius: 0.375rem;
+ overflow: hidden;
+}
+.theme-boocode-classic .bc-card:hover {
+ border-color: color-mix(in srgb, var(--bc-orange) 55%, transparent);
+ box-shadow:
+ 0 0 0 1px color-mix(in srgb, var(--bc-orange) 22%, transparent),
+ 0 6px 18px -8px color-mix(in srgb, var(--bc-orange) 40%, transparent);
+}
+html.bc-anim-on.theme-boocode-classic .bc-card {
+ transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
+}
+html.bc-anim-on.theme-boocode-classic .bc-card:hover {
+ transform: translateY(-1px);
+}
+
+/* ── Keyframes (global names, referenced only under the gate above) ───────── */
+@keyframes bc-caret-blink {
+ 0%, 49% { opacity: 1; }
+ 50%, 100% { opacity: 0; }
+}
+@keyframes bc-scanline {
+ 0% { transform: translateY(-100%); }
+ 100% { transform: translateY(100%); }
+}
+
+/* ── Reduced-motion belt-and-suspenders ───────────────────────────────────
+ bc-anim-on already excludes reduced-motion (ThemeFx), but if the class ever
+ lingered, this hard-stops every animation and the lift transform. */
+@media (prefers-reduced-motion: reduce) {
+ .theme-boocode-classic .boocode-display::after,
+ .theme-boocode-classic .bc-caret,
+ .theme-boocode-classic .bc-status-syncing::before {
+ animation: none;
+ opacity: 1;
+ }
+ .theme-boocode-classic .h-dvh.bg-background::after {
+ animation: none;
+ display: none;
+ }
+ .theme-boocode-classic [data-slot="card"],
+ .theme-boocode-classic .bc-card {
+ transition: none;
+ }
+ .theme-boocode-classic [data-slot="card"]:hover,
+ .theme-boocode-classic .bc-card:hover {
+ transform: none;
+ }
+}
diff --git a/apps/web/src/styles/themes/effects/boocode-override-fx.css b/apps/web/src/styles/themes/effects/boocode-override-fx.css
new file mode 100644
index 0000000..c5a49c0
--- /dev/null
+++ b/apps/web/src/styles/themes/effects/boocode-override-fx.css
@@ -0,0 +1,288 @@
+/* BooCode Override — effects layer. Full neon cyberpunk: heavy bloom/glow,
+ chromatic-glitch wordmark, glitch-on-hover, strong scanlines, neon border
+ pulses. Magenta #ff2d78 + cyan #00e5ff + violet #9b5de5 on blue-black.
+
+ Scoping contract (immutable, same as boocode-classic-fx.css):
+ - EVERY rule is scoped under `.theme-boocode-override` so the other 22 themes
+ are byte-for-byte unaffected.
+ - EVERY continuous keyframe animation is *additionally* scoped under
+ `html.bc-anim-on.theme-boocode-override`. ThemeFx sets `bc-anim-on` only
+ when the Animated-background toggle is ON *and* prefers-reduced-motion is
+ not set — so toggling either off removes the class and freezes all motion.
+ @keyframes names are global (CSS can't scope the at-rule itself); they are
+ `bco-`-prefixed and only *referenced* under the gated selector.
+
+ Static vs. gated split:
+ - STATIC (always, under .theme-boocode-override): the neon palette, a bloom
+ vignette painted on the app-shell background, static neon rims/glows on the
+ primary action / cards / inputs, and a neon focus ring. This is the
+ "static tint" fallback shown when the toggle is OFF or reduced-motion is on.
+ - GATED (under html.bc-anim-on.theme-boocode-override): the NeonField canvas
+ (mounted by ThemeFx), the scanline texture + moving sweep, the wordmark
+ chromatic glitch, the hover glitch jitter, and the idle neon pulse.
+
+ AA: neon is used ONLY for accents/borders/glow and the (large display)
+ wordmark — never body text. Body/panel text keeps the light #cde0ff /
+ #8aa9cd tokens over the high-alpha translucent surfaces (see
+ boocode-override.css). Glitch uses transform / text-shadow / clip-path only,
+ so it never reflows layout. */
+
+.theme-boocode-override {
+ --bco-magenta: #ff2d78;
+ --bco-cyan: #00e5ff;
+ --bco-violet: #9b5de5;
+ --bco-glow-magenta: 0 0 22px color-mix(in srgb, var(--bco-magenta) 42%, transparent);
+ --bco-glow-cyan: 0 0 22px color-mix(in srgb, var(--bco-cyan) 38%, transparent);
+}
+
+/* ── Bloom vignette on the app shell ──────────────────────────────────────
+ Painted as background-image on the AppShell root (`.h-dvh.bg-background`,
+ unique to App.tsx). The background-COLOR stays the translucent --background
+ token so the NeonField canvas (z-0, behind the z-10 root) still shows through
+ the panel gaps; these radial neon pools sit over the field for an atmospheric
+ bloom. Static → this is part of the reduced-motion / toggle-off fallback. */
+.theme-boocode-override .h-dvh.bg-background {
+ background-color: var(--background);
+ background-image:
+ radial-gradient(125% 90% at 50% 8%,
+ color-mix(in oklab, var(--bco-magenta) 12%, transparent), transparent 46%),
+ radial-gradient(120% 85% at 6% 102%,
+ color-mix(in oklab, var(--bco-cyan) 11%, transparent), transparent 50%),
+ radial-gradient(120% 95% at 100% 100%,
+ color-mix(in oklab, var(--bco-violet) 12%, transparent), transparent 52%),
+ radial-gradient(140% 120% at 50% 50%,
+ transparent 60%, rgba(2, 4, 10, 0.55) 100%);
+ background-repeat: no-repeat;
+}
+
+/* ── Strong scanlines (gated) ─────────────────────────────────────────────
+ ::before = a fine static CRT scanline texture; ::after = a bright neon band
+ that sweeps the viewport. Both are `position: fixed` (never create document
+ overflow), `pointer-events: none`, and use `mix-blend-mode: screen` so they
+ only LIGHTEN — text underneath never loses contrast. Both live ONLY under the
+ gate, so motion-off / reduced-motion removes scanlines entirely (the bloom
+ vignette above is the static fallback). z-index:2/3 overlays the static panels
+ inside the shell; portaled dialogs (separate stacking context) stay clean. */
+html.bc-anim-on.theme-boocode-override .h-dvh.bg-background::before {
+ content: '';
+ position: fixed;
+ inset: 0;
+ z-index: 2;
+ pointer-events: none;
+ background-image: repeating-linear-gradient(
+ 0deg,
+ color-mix(in srgb, var(--bco-cyan) 9%, transparent) 0px,
+ color-mix(in srgb, var(--bco-cyan) 9%, transparent) 1px,
+ transparent 1px,
+ transparent 3px
+ );
+ mix-blend-mode: screen;
+ opacity: 0.55;
+}
+html.bc-anim-on.theme-boocode-override .h-dvh.bg-background::after {
+ content: '';
+ position: fixed;
+ inset: 0;
+ z-index: 3;
+ pointer-events: none;
+ background: linear-gradient(
+ 180deg,
+ transparent 44%,
+ color-mix(in srgb, var(--bco-cyan) 10%, transparent) 49%,
+ color-mix(in srgb, var(--bco-magenta) 12%, transparent) 51%,
+ transparent 56%
+ );
+ mix-blend-mode: screen;
+ animation: bco-scan-sweep 7s linear infinite;
+ will-change: transform;
+}
+
+/* ── Neon chromatic-glitch wordmark ───────────────────────────────────────
+ The "BooCode" display heading (Home.tsx, additive `boocode-display` class —
+ the same hook BooCode Classic restyles; only one theme is ever active).
+ Orbitron 800 (JS-imported in main.tsx). A near-white core with stacked cyan +
+ magenta glow gives the neon tube look; the gated glitch jitters the colour
+ split + a clip slice via transform/text-shadow only → zero layout shift. */
+.theme-boocode-override .boocode-display {
+ font-family: 'Orbitron', var(--font-sans);
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ color: #eafaff;
+ text-shadow:
+ 0 0 2px color-mix(in srgb, var(--bco-cyan) 80%, transparent),
+ -0.02em 0 0 color-mix(in srgb, var(--bco-magenta) 70%, transparent),
+ 0.02em 0 0 color-mix(in srgb, var(--bco-cyan) 70%, transparent),
+ 0 0 18px color-mix(in srgb, var(--bco-cyan) 55%, transparent),
+ 0 0 34px color-mix(in srgb, var(--bco-magenta) 40%, transparent);
+}
+html.bc-anim-on.theme-boocode-override .boocode-display {
+ animation: bco-glitch 5.5s steps(1, end) infinite;
+}
+
+/* ── Primary action: neon rim + idle pulse + hover bloom + press squash ────
+ The default-variant Button (Send / primary confirm). Static magenta rim +
+ drop glow (always); a gentle idle pulse and a hover glitch jitter under the
+ gate; box-shadow / transform / filter only → never reflows. */
+.theme-boocode-override [data-slot="button"][data-variant="default"] {
+ box-shadow:
+ 0 0 0 1px color-mix(in srgb, var(--bco-magenta) 55%, transparent),
+ 0 4px 20px -6px color-mix(in srgb, var(--bco-magenta) 60%, transparent);
+}
+.theme-boocode-override [data-slot="button"][data-variant="default"]:not([disabled]):hover {
+ box-shadow:
+ 0 0 0 1px color-mix(in srgb, var(--bco-magenta) 85%, transparent),
+ 0 0 16px -2px color-mix(in srgb, var(--bco-magenta) 70%, transparent),
+ 0 8px 30px -6px color-mix(in srgb, var(--bco-cyan) 55%, transparent);
+ filter: brightness(1.08);
+}
+html.bc-anim-on.theme-boocode-override [data-slot="button"][data-variant="default"] {
+ transition: box-shadow 200ms ease, transform 120ms ease, filter 160ms ease;
+ animation: bco-pulse 3.4s ease-in-out infinite;
+}
+html.bc-anim-on.theme-boocode-override [data-slot="button"][data-variant="default"]:not([disabled]):hover {
+ animation: bco-hover-glitch 320ms steps(1, end) 1;
+}
+html.bc-anim-on.theme-boocode-override [data-slot="button"][data-variant="default"]:not([disabled]):active {
+ transform: translateY(0.5px) scale(0.985);
+}
+
+/* ── Cards: neon edge + hover glow/lift ───────────────────────────────────
+ shadcn Card primitive (`data-slot="card"`). Static cyan hairline; on hover a
+ cyan/magenta rim glow + 1px lift (lift + transition gated). */
+.theme-boocode-override [data-slot="card"] {
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--bco-cyan) 12%, transparent);
+}
+.theme-boocode-override [data-slot="card"]:hover {
+ border-color: color-mix(in srgb, var(--bco-cyan) 55%, transparent);
+ box-shadow:
+ 0 0 0 1px color-mix(in srgb, var(--bco-cyan) 38%, transparent),
+ 0 0 22px -6px color-mix(in srgb, var(--bco-magenta) 45%, transparent),
+ 0 8px 24px -10px color-mix(in srgb, var(--bco-cyan) 50%, transparent);
+}
+html.bc-anim-on.theme-boocode-override [data-slot="card"] {
+ transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
+}
+html.bc-anim-on.theme-boocode-override [data-slot="card"]:hover {
+ transform: translateY(-1px);
+}
+
+/* ── Sidebar edge-light + neon focus ring ─────────────────────────────────
+ A static cyan inset hairline down the sidebar's trailing edge; a magenta neon
+ focus ring on inputs/buttons (focus-visible only, static — focus feedback is
+ acceptable under reduced motion). */
+.theme-boocode-override .bg-sidebar {
+ box-shadow: inset -1px 0 0 0 color-mix(in srgb, var(--bco-cyan) 16%, transparent);
+}
+.theme-boocode-override [data-slot="input"]:focus-visible,
+.theme-boocode-override [data-slot="button"]:focus-visible,
+.theme-boocode-override [data-slot="textarea"]:focus-visible {
+ box-shadow:
+ 0 0 0 1px color-mix(in srgb, var(--bco-magenta) 70%, transparent),
+ 0 0 14px -2px color-mix(in srgb, var(--bco-magenta) 55%, transparent);
+ outline: none;
+}
+
+/* ── Links / accent text glow ─────────────────────────────────────────────
+ Anchors inside content get a soft cyan glow on hover — accent, not body. */
+.theme-boocode-override a:hover {
+ text-shadow: 0 0 10px color-mix(in srgb, var(--bco-cyan) 55%, transparent);
+}
+
+/* ── Keyframes (global names, referenced only under the gate above) ───────── */
+
+/* Scanline sweep: a bright neon band drifts top→bottom. */
+@keyframes bco-scan-sweep {
+ 0% { transform: translateY(-100%); }
+ 100% { transform: translateY(100%); }
+}
+
+/* Idle neon pulse on the primary action: the glow swells and settles. */
+@keyframes bco-pulse {
+ 0%, 100% {
+ box-shadow:
+ 0 0 0 1px color-mix(in srgb, var(--bco-magenta) 55%, transparent),
+ 0 4px 20px -6px color-mix(in srgb, var(--bco-magenta) 55%, transparent);
+ }
+ 50% {
+ box-shadow:
+ 0 0 0 1px color-mix(in srgb, var(--bco-magenta) 80%, transparent),
+ 0 0 18px -2px color-mix(in srgb, var(--bco-magenta) 60%, transparent),
+ 0 6px 26px -6px color-mix(in srgb, var(--bco-cyan) 45%, transparent);
+ }
+}
+
+/* Wordmark chromatic glitch: mostly steady, with brief jitter bursts. Only
+ transform / text-shadow / clip-path change → no reflow. */
+@keyframes bco-glitch {
+ 0%, 88%, 100% {
+ transform: translate3d(0, 0, 0);
+ clip-path: none;
+ text-shadow:
+ 0 0 2px color-mix(in srgb, var(--bco-cyan) 80%, transparent),
+ -0.02em 0 0 color-mix(in srgb, var(--bco-magenta) 70%, transparent),
+ 0.02em 0 0 color-mix(in srgb, var(--bco-cyan) 70%, transparent),
+ 0 0 18px color-mix(in srgb, var(--bco-cyan) 55%, transparent),
+ 0 0 34px color-mix(in srgb, var(--bco-magenta) 40%, transparent);
+ }
+ 90% {
+ transform: translate3d(-2px, 0, 0);
+ clip-path: inset(8% 0 62% 0);
+ text-shadow:
+ -0.08em 0 0 color-mix(in srgb, var(--bco-magenta) 90%, transparent),
+ 0.08em 0 0 color-mix(in srgb, var(--bco-cyan) 90%, transparent),
+ 0 0 22px color-mix(in srgb, var(--bco-cyan) 60%, transparent);
+ }
+ 92% {
+ transform: translate3d(2px, 0, 0);
+ clip-path: inset(54% 0 18% 0);
+ text-shadow:
+ 0.1em 0 0 color-mix(in srgb, var(--bco-magenta) 90%, transparent),
+ -0.06em 0 0 color-mix(in srgb, var(--bco-cyan) 85%, transparent),
+ 0 0 22px color-mix(in srgb, var(--bco-magenta) 60%, transparent);
+ }
+ 94% {
+ transform: translate3d(-1px, 0, 0);
+ clip-path: inset(34% 0 40% 0);
+ text-shadow:
+ -0.05em 0 0 color-mix(in srgb, var(--bco-cyan) 85%, transparent),
+ 0.05em 0 0 color-mix(in srgb, var(--bco-magenta) 85%, transparent);
+ }
+}
+
+/* Hover glitch on the primary button: a single quick colour-split jitter. */
+@keyframes bco-hover-glitch {
+ 0% { transform: translate3d(0, 0, 0); }
+ 25% { transform: translate3d(-1.5px, 0, 0); }
+ 50% { transform: translate3d(1.5px, 0, 0); }
+ 75% { transform: translate3d(-0.5px, 0, 0); }
+ 100% { transform: translate3d(0, 0, 0); }
+}
+
+/* ── Reduced-motion belt-and-suspenders ───────────────────────────────────
+ bc-anim-on already excludes reduced-motion (ThemeFx), but if the class ever
+ lingered, this hard-stops every animation and the lift/jitter transforms. The
+ static neon rims, bloom vignette and focus ring remain (the "static tint"). */
+@media (prefers-reduced-motion: reduce) {
+ .theme-boocode-override .boocode-display,
+ .theme-boocode-override .h-dvh.bg-background::before,
+ .theme-boocode-override .h-dvh.bg-background::after,
+ .theme-boocode-override [data-slot="button"][data-variant="default"],
+ .theme-boocode-override [data-slot="button"][data-variant="default"]:hover {
+ animation: none;
+ }
+ .theme-boocode-override .h-dvh.bg-background::after {
+ display: none;
+ }
+ .theme-boocode-override .boocode-display {
+ transform: none;
+ clip-path: none;
+ }
+ .theme-boocode-override [data-slot="card"],
+ .theme-boocode-override [data-slot="button"][data-variant="default"] {
+ transition: none;
+ }
+ .theme-boocode-override [data-slot="card"]:hover,
+ .theme-boocode-override [data-slot="button"][data-variant="default"]:active {
+ transform: none;
+ }
+}
diff --git a/apps/web/src/styles/themes/effects/boocode-plus-fx.css b/apps/web/src/styles/themes/effects/boocode-plus-fx.css
new file mode 100644
index 0000000..2de5411
--- /dev/null
+++ b/apps/web/src/styles/themes/effects/boocode-plus-fx.css
@@ -0,0 +1,205 @@
+/* BooCode+ — effects layer. Tasteful sci-fi: calm, premium, Linear-grade.
+ EVERY rule is scoped under `.theme-boocode-plus.dark` so (a) the other 22
+ themes are byte-for-byte unaffected and (b) the light variant stays a clean
+ flat slate — dark is the priority, so the glass/gradient/glow ship dark-only.
+
+ No canvas, no animation loop, no scanlines. Just: a faint STATIC ambient
+ gradient on the app background, frosted glass on CHROME ONLY (rails, menus,
+ dialogs, popovers, the composer, pane top-bars), a restrained indigo glow on
+ the primary action, and spring-eased transitions on chrome state changes.
+
+ iOS / quality guardrails honored:
+ - backdrop-blur capped at 8–12px; no nested blur-on-blur (the dialog
+ overlay's own backdrop-blur is neutralized so only the dialog CONTENT
+ frosts — one layer).
+ - opaque fallback: every glass rule lives inside @supports(backdrop-filter),
+ so a browser without it keeps the plain opaque Tailwind utility
+ (bg-sidebar/bg-popover/bg-card = solid token). prefers-reduced-transparency
+ forces the same opaque path.
+ - spring via cubic-bezier (NOT the linear() function — Safari <17.2 gap),
+ confined to box-shadow / transform / color / filter — never layout props.
+ - chat messages, tool-call cards and code blocks (bg-muted/30·/20, bg-card
+ bubbles) are deliberately NOT targeted — glass never sits behind reading
+ content; the ambient gradient behind it stays faint & opaque-based (AA).
+
+ Glass targets are stable, verified hooks: bg-sidebar (both rails), bg-popover
+ (all menus/dialogs/popovers/sheets), the composer's unique
+ focus-within:ring-primary/15 box, [data-slot=button][data-variant=default]
+ (the primary action), and the compound .border-b.bg-muted/30·/20 (pane
+ top-bars — distinct from message bubbles, which use .border.rounded-lg). */
+
+.theme-boocode-plus.dark {
+ /* Spring curves. --bcp-spring overshoots a touch (press/glow bloom);
+ --bcp-ease is a smooth Linear-style decel for color/opacity. */
+ --bcp-spring: cubic-bezier(0.34, 1.42, 0.5, 1);
+ --bcp-ease: cubic-bezier(0.32, 0.72, 0, 1);
+ --bcp-blur: 10px;
+}
+
+/* ── Static ambient gradient ─────────────────────────────────────────────
+ Painted on the app-shell root (.h-dvh is unique to App.tsx). Opaque base
+ (var(--background)) + three faint indigo radials = depth without cost. The
+ MessageList scroll area is transparent, so the gradient reads faintly behind
+ chat — but it's static and opaque-based: the brightest stop (indigo @14% over
+ #0f1117) still gives ~10:1 for #e3e6f1 body text. The glass rails blur it for
+ a premium parallax-of-light feel. No background-attachment:fixed (iOS jank);
+ .h-dvh doesn't scroll, so the field is already viewport-stable. */
+.theme-boocode-plus.dark .h-dvh {
+ background-color: var(--background);
+ background-image:
+ radial-gradient(1200px 760px at 8% -12%,
+ color-mix(in oklab, #5e6ad2 14%, transparent), transparent 58%),
+ radial-gradient(1000px 680px at 102% 4%,
+ color-mix(in oklab, #5a73d8 10%, transparent), transparent 54%),
+ radial-gradient(1100px 900px at 50% 128%,
+ color-mix(in oklab, #2c3270 16%, transparent), transparent 60%);
+ background-repeat: no-repeat;
+}
+
+/* ── Frosted glass on chrome ─────────────────────────────────────────────
+ Progressive enhancement: only inside @supports does the surface go
+ translucent + blur. Without backdrop-filter support the rules vanish and the
+ plain opaque utility (solid token bg) shows — that IS the opaque fallback.
+ Each background has an rgba() line first (covers the rare browser that has
+ backdrop-filter but not color-mix, e.g. Safari 15) then the color-mix line. */
+@supports ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
+ /* Side rails (ProjectSidebar + RightRail). Blur the ambient gradient behind
+ them → the signature glass plane. Kept at 80% so the dense nav text stays
+ crisp; the blur + saturate sells the effect, not heavy transparency. */
+ .theme-boocode-plus.dark .bg-sidebar {
+ background-color: rgba(19, 21, 29, 0.80);
+ background-color: color-mix(in oklab, var(--sidebar) 80%, transparent);
+ -webkit-backdrop-filter: blur(var(--bcp-blur)) saturate(140%);
+ backdrop-filter: blur(var(--bcp-blur)) saturate(140%);
+ }
+
+ /* Every floating surface: dialogs, dropdown / context / sub menus, the
+ @-mention & slash pickers, the mobile bottom sheet, the message-actions
+ menu. All carry bg-popover and all float over content → one clean blur. */
+ .theme-boocode-plus.dark .bg-popover {
+ background-color: rgba(26, 29, 46, 0.84);
+ background-color: color-mix(in oklab, var(--popover) 84%, transparent);
+ -webkit-backdrop-filter: blur(12px) saturate(150%);
+ backdrop-filter: blur(12px) saturate(150%);
+ }
+
+ /* The composer message box (ChatInput) — unique focus-within ring hook.
+ Frosts the tail of the conversation as it scrolls beneath. 82% keeps the
+ textarea text readable even over a bright code block underneath. */
+ .theme-boocode-plus.dark .focus-within\:ring-primary\/15 {
+ background-color: rgba(26, 29, 46, 0.82);
+ background-color: color-mix(in oklab, var(--card) 82%, transparent);
+ -webkit-backdrop-filter: blur(var(--bcp-blur)) saturate(140%);
+ backdrop-filter: blur(var(--bcp-blur)) saturate(140%);
+ }
+
+ /* Pane top-bars (Coder/Workspace/terminal-hotkey/artifact headers). The
+ compound .border-b.bg-muted/N selector excludes chat bubbles & tool cards
+ (those use .border.rounded-lg). Lightest blur (8px) + an inset top hairline
+ for the edge-lit premium feel. */
+ .theme-boocode-plus.dark .border-b.bg-muted\/30,
+ .theme-boocode-plus.dark .border-b.bg-muted\/20 {
+ background-color: rgba(28, 32, 47, 0.62);
+ background-color: color-mix(in oklab, var(--muted) 62%, transparent);
+ -webkit-backdrop-filter: blur(8px) saturate(130%);
+ backdrop-filter: blur(8px) saturate(130%);
+ box-shadow: inset 0 1px 0 0 color-mix(in oklab, #aab2ff 8%, transparent);
+ }
+}
+
+/* Dialog scrim: deepen the dim (the stock bg-black/10 is too light to anchor a
+ frosted modal) and kill its own backdrop-blur so the only blur layer is the
+ dialog CONTENT above — no nested blur-on-blur. Unconditional: when blur is
+ unsupported the stock overlay had no blur anyway, and the deeper scrim is
+ harmless. */
+.theme-boocode-plus.dark [data-slot="dialog-overlay"] {
+ background-color: rgba(7, 8, 14, 0.55);
+ -webkit-backdrop-filter: none;
+ backdrop-filter: none;
+}
+
+/* ── Restrained indigo glow on the primary action ────────────────────────
+ The default-variant Button only (Send, primary confirm). Resting: a hairline
+ indigo rim + soft drop glow. Hover: the glow blooms with the spring curve.
+ Press: a composited squash. box-shadow/transform/filter only — no reflow. */
+.theme-boocode-plus.dark [data-slot="button"][data-variant="default"] {
+ box-shadow:
+ 0 0 0 1px color-mix(in oklab, #5e6ad2 38%, transparent),
+ 0 4px 16px -6px color-mix(in oklab, #5e6ad2 50%, transparent);
+ transition:
+ box-shadow 0.28s var(--bcp-spring),
+ transform 0.2s var(--bcp-spring),
+ filter 0.2s var(--bcp-ease),
+ background-color 0.18s var(--bcp-ease);
+}
+.theme-boocode-plus.dark [data-slot="button"][data-variant="default"]:not([disabled]):hover {
+ box-shadow:
+ 0 0 0 1px color-mix(in oklab, #5e6ad2 60%, transparent),
+ 0 8px 26px -6px color-mix(in oklab, #5e6ad2 68%, transparent);
+ filter: brightness(1.06);
+}
+.theme-boocode-plus.dark [data-slot="button"][data-variant="default"]:not([disabled]):active {
+ transform: translateY(0.5px) scale(0.985);
+}
+
+/* ── Spring-eased state on the rest of the chrome ─────────────────────────
+ Smooth (non-overshoot) color/bg transitions on menu items and the composer
+ focus, so hovers and focus feel intentional, not instant. Color props only. */
+.theme-boocode-plus.dark [data-slot="dropdown-menu-item"],
+.theme-boocode-plus.dark [data-slot="context-menu-item"],
+.theme-boocode-plus.dark [data-slot="dropdown-menu-sub-trigger"],
+.theme-boocode-plus.dark [data-slot="context-menu-sub-trigger"] {
+ transition:
+ background-color 0.16s var(--bcp-ease),
+ color 0.16s var(--bcp-ease);
+}
+.theme-boocode-plus.dark .focus-within\:ring-primary\/15 {
+ transition:
+ border-color 0.22s var(--bcp-ease),
+ box-shadow 0.22s var(--bcp-spring),
+ background-color 0.22s var(--bcp-ease);
+}
+
+/* ── Accessibility fallbacks ──────────────────────────────────────────────
+ Reduced transparency: drop every glass surface to its solid token bg and
+ remove all blur. Declared after the @supports block so it wins. */
+@media (prefers-reduced-transparency: reduce) {
+ .theme-boocode-plus.dark .bg-sidebar {
+ background-color: var(--sidebar);
+ -webkit-backdrop-filter: none;
+ backdrop-filter: none;
+ }
+ .theme-boocode-plus.dark .bg-popover {
+ background-color: var(--popover);
+ -webkit-backdrop-filter: none;
+ backdrop-filter: none;
+ }
+ .theme-boocode-plus.dark .focus-within\:ring-primary\/15 {
+ background-color: var(--card);
+ -webkit-backdrop-filter: none;
+ backdrop-filter: none;
+ }
+ .theme-boocode-plus.dark .border-b.bg-muted\/30,
+ .theme-boocode-plus.dark .border-b.bg-muted\/20 {
+ background-color: var(--muted);
+ -webkit-backdrop-filter: none;
+ backdrop-filter: none;
+ box-shadow: none;
+ }
+}
+
+/* Reduced motion: no spring/transition, no press transform. The ambient
+ gradient is static, so it (correctly) stays. */
+@media (prefers-reduced-motion: reduce) {
+ .theme-boocode-plus.dark [data-slot="button"][data-variant="default"],
+ .theme-boocode-plus.dark [data-slot="dropdown-menu-item"],
+ .theme-boocode-plus.dark [data-slot="context-menu-item"],
+ .theme-boocode-plus.dark [data-slot="dropdown-menu-sub-trigger"],
+ .theme-boocode-plus.dark [data-slot="context-menu-sub-trigger"],
+ .theme-boocode-plus.dark .focus-within\:ring-primary\/15 {
+ transition: none;
+ }
+ .theme-boocode-plus.dark [data-slot="button"][data-variant="default"]:not([disabled]):active {
+ transform: none;
+ }
+}
diff --git a/apps/web/src/styles/themes/ember-polish.css b/apps/web/src/styles/themes/ember-polish.css
new file mode 100644
index 0000000..b2648ac
--- /dev/null
+++ b/apps/web/src/styles/themes/ember-polish.css
@@ -0,0 +1,153 @@
+/* BooCode (Ember) polish layer — a restrained refinement of the default
+ theme, NOT a redesign. Keeps the orange-on-charcoal identity; just gives
+ the flat chrome gentle depth, the primary action a warm glow, a crisp
+ keyboard focus ring, and a hair tighter title tracking. Calm enough to
+ live in for hours — no background animation, no scanlines, no canvas.
+
+ SCOPE DISCIPLINE (the cross-theme regression trap): EVERY rule below is
+ scoped under `.theme-ember`, so the other 22 themes are byte-for-byte
+ unaffected. No global body, html, universal, or heading rule; no global
+ line-height or letter-spacing change (those reflow every theme). The few
+ `.theme-ember.dark` rules are still ember-only — both classes must be
+ present — so they never leak either.
+
+ This sheet is imported UNLAYERED (globals.css, after the tailwindcss
+ import), so these rules win over Tailwind's layered utilities. A bare
+ box-shadow therefore REPLACES a component's `ring-1` hairline — so every
+ elevation below composes the hairline back in via --ember-hairline (except
+ the composer, which already has a real CSS `border`).
+
+ Hooks are the same stable ones BooCode+ uses: [data-slot=card],
+ [data-slot=dialog-content], the menu [data-slot=*-content]s, the composer's
+ unique `.focus-within:ring-primary/15` box, and the primary
+ [data-slot=button][data-variant=default]. */
+
+.theme-ember {
+ /* Hairline = the stock `ring-foreground/10` edge, re-expressed as a shadow
+ so it can ride alongside the elevation shadows in one box-shadow list. */
+ --ember-hairline: 0 0 0 1px color-mix(in oklab, var(--foreground) 9%, transparent);
+
+ /* Consistent elevation scale. Resting surfaces (cards, composer) → 1;
+ floating chrome (menus) → 2; the modal layer (dialog) → 3. Drop shadows
+ are the primary depth cue in light mode; tuned calm (Material-ish alphas),
+ never loud. */
+ --ember-shadow-1: 0 1px 2px -1px rgb(0 0 0 / 0.40), 0 3px 8px -3px rgb(0 0 0 / 0.28);
+ --ember-shadow-2: 0 3px 8px -3px rgb(0 0 0 / 0.44), 0 12px 28px -8px rgb(0 0 0 / 0.36);
+ --ember-shadow-3: 0 6px 14px -4px rgb(0 0 0 / 0.50), 0 24px 56px -14px rgb(0 0 0 / 0.44);
+
+ /* Edge-light: a no-op in light mode (drop shadows do the work there). */
+ --ember-edge: 0 0 transparent;
+
+ /* Restrained warm glow for the PRIMARY action only. Rest = a faint orange
+ rim + soft drop; hover blooms. Colored shadows read on dark charcoal where
+ black shadows fade, so the primary stays legible as "the" action. */
+ --ember-glow-rest:
+ 0 0 0 1px color-mix(in oklab, var(--primary) 22%, transparent),
+ 0 2px 8px -3px color-mix(in oklab, var(--primary) 32%, transparent),
+ 0 1px 2px -1px rgb(0 0 0 / 0.40);
+ --ember-glow-hover:
+ 0 0 0 1px color-mix(in oklab, var(--primary) 40%, transparent),
+ 0 5px 18px -4px color-mix(in oklab, var(--primary) 55%, transparent),
+ 0 1px 2px -1px rgb(0 0 0 / 0.40);
+
+ /* A gentle decel curve (no overshoot) — intentionally calmer than
+ BooCode+'s spring, to suit a theme meant for long sessions. */
+ --ember-ease: cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+/* On near-black, drop shadows barely read — a hair of warm top edge-light is
+ the real "lifted" cue in dark mode. Tinted to the warm foreground so it
+ stays on-brand and never cool. */
+.theme-ember.dark {
+ --ember-edge: inset 0 1px 0 0 color-mix(in oklab, var(--foreground) 7%, transparent);
+}
+
+/* ── Elevation ────────────────────────────────────────────────────────────
+ Each surface keeps its edge (hairline, re-composed) and gains depth. */
+
+/* Cards — resting elevation. */
+.theme-ember [data-slot="card"] {
+ box-shadow: var(--ember-edge), var(--ember-hairline), var(--ember-shadow-1);
+}
+
+/* The composer message box (ChatInput) — its own unique focus-within hook.
+ It already has a real CSS `border`, so NO hairline here (that would double
+ the edge). Only at rest: on focus-within the stock orange ring/border takes
+ over, so we step aside with :not(:focus-within). */
+.theme-ember .focus-within\:ring-primary\/15:not(:focus-within) {
+ box-shadow: var(--ember-edge), var(--ember-shadow-1);
+}
+
+/* Floating menus — dropdown / context / their submenus. (Replaces the stock
+ shadow-md/lg with the consistent themed scale.) */
+.theme-ember [data-slot="dropdown-menu-content"],
+.theme-ember [data-slot="dropdown-menu-sub-content"],
+.theme-ember [data-slot="context-menu-content"],
+.theme-ember [data-slot="context-menu-sub-content"] {
+ box-shadow: var(--ember-edge), var(--ember-hairline), var(--ember-shadow-2);
+}
+
+/* Dialog — the modal layer, strongest depth to separate it from the (light)
+ scrim. */
+.theme-ember [data-slot="dialog-content"] {
+ box-shadow: var(--ember-edge), var(--ember-hairline), var(--ember-shadow-3);
+}
+
+/* ── Primary action glow ──────────────────────────────────────────────────
+ The default-variant Button only (Send, primary confirm). box-shadow /
+ transform / filter only — never a layout-triggering prop, so no shift. */
+.theme-ember [data-slot="button"][data-variant="default"] {
+ box-shadow: var(--ember-glow-rest);
+ transition:
+ box-shadow 0.26s var(--ember-ease),
+ transform 0.18s var(--ember-ease),
+ filter 0.18s var(--ember-ease);
+}
+.theme-ember [data-slot="button"][data-variant="default"]:not([disabled]):hover {
+ box-shadow: var(--ember-glow-hover);
+ filter: brightness(1.04);
+}
+.theme-ember [data-slot="button"][data-variant="default"]:not([disabled]):active {
+ transform: translateY(0.5px) scale(0.99);
+}
+
+/* ── Keyboard focus ring ──────────────────────────────────────────────────
+ A crisp, fully-opaque 2px orange outline replaces the stock soft 3px/50%
+ ring — cleaner and more visible for keyboard nav. The outline follows each
+ control's border-radius automatically. Form fields are intentionally left
+ alone (the composer owns its focus-within ring; inputs keep theirs). */
+.theme-ember button:focus-visible,
+.theme-ember [data-slot="button"]:focus-visible,
+.theme-ember a:focus-visible,
+.theme-ember [role="switch"]:focus-visible {
+ outline: 2px solid var(--ring);
+ outline-offset: 2px;
+}
+/* Drop the stock soft box-shadow ring on non-primary buttons so only the crisp
+ outline shows. (The primary button keeps its glow box-shadow on focus.) */
+.theme-ember [data-slot="button"]:not([data-variant="default"]):focus-visible {
+ box-shadow: none;
+}
+
+/* ── Type rhythm ──────────────────────────────────────────────────────────
+ The only safe scoped tightening: a hair of negative tracking on display /
+ title text. NO line-height changes and nothing global — body copy, chat
+ text, and code keep their exact metrics, so reading rhythm and the other
+ themes are untouched. */
+.theme-ember .boocode-display,
+.theme-ember [data-slot="dialog-title"],
+.theme-ember [data-slot="card-title"] {
+ letter-spacing: -0.01em;
+}
+
+/* ── Reduced motion ───────────────────────────────────────────────────────
+ The glow/shadows/outline are static; only the primary button transitions.
+ Disable them (and the press transform) when the user asks for less motion. */
+@media (prefers-reduced-motion: reduce) {
+ .theme-ember [data-slot="button"][data-variant="default"] {
+ transition: none;
+ }
+ .theme-ember [data-slot="button"][data-variant="default"]:not([disabled]):active {
+ transform: none;
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 91c5865..c8455a3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -158,6 +158,9 @@ importers:
'@fontsource-variable/jetbrains-mono':
specifier: ^5.2.8
version: 5.2.8
+ '@fontsource/orbitron':
+ specifier: ^5.2.0
+ version: 5.2.8
'@xterm/addon-fit':
specifier: 0.10.0
version: 0.10.0(@xterm/xterm@5.5.0)
@@ -839,6 +842,9 @@ packages:
'@fontsource-variable/jetbrains-mono@5.2.8':
resolution: {integrity: sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==}
+ '@fontsource/orbitron@5.2.8':
+ resolution: {integrity: sha512-ruzrDl5vnqNykk5DZWY0Ezj4aeFZSbCnwJTc/98ojNJHSsHhlhT2r7rwQrA5sptmF8JtB8TQTAvlfRvcV28RPw==}
+
'@hono/node-server@1.19.14':
resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
engines: {node: '>=18.14.1'}
@@ -4705,6 +4711,8 @@ snapshots:
'@fontsource-variable/jetbrains-mono@5.2.8': {}
+ '@fontsource/orbitron@5.2.8': {}
+
'@hono/node-server@1.19.14(hono@4.12.18)':
dependencies:
hono: 4.12.18