feat: futuristic theme ladder + stacked landing banner

Add three opt-in dark themes (BooCode+, BooCode Classic, BooCode
Override) plus an in-place Ember polish, on a class-scoped effects
engine: matrix rain, a neon grid field, and frosted glass, all gated
by a localStorage "Animated background" toggle and prefers-reduced-
motion. Extend the server theme_id whitelist so the new ids persist,
and replace the Home landing wordmark with the stacked mascot +
wordmark banner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 14:16:59 +00:00
parent d10d79399b
commit fc4fbb0b7e
21 changed files with 1822 additions and 30 deletions

View File

@@ -45,6 +45,10 @@ const THEME_IDS = [
'cobalt', 'cobalt',
'midnight-sapphire', 'midnight-sapphire',
'ember', 'ember',
// futuristic ladder (opt-in) — kept in sync with apps/web/src/lib/theme.ts THEMES
'boocode-plus',
'boocode-classic',
'boocode-override',
] as const; ] as const;
const THEME_MODES = ['dark', 'light', 'system'] as const; const THEME_MODES = ['dark', 'light', 'system'] as const;

View File

@@ -7,23 +7,29 @@
<script> <script>
// themes-v1 FOUC guard: read the last-applied theme from localStorage // themes-v1 FOUC guard: read the last-applied theme from localStorage
// and stamp the class on <html> before React mounts. Falls back to // and stamp the class on <html> before React mounts. Falls back to
// obsidian + dark when no cache. Light-only themes (ivory, chalk) with // ember + dark when no cache. Light-only themes (ivory, chalk) with a
// a dark mode pref fall back to obsidian dark — mirrors the rule in // dark mode pref fall back to ember dark — mirrors effectiveThemeId().
// lib/theme.ts effectiveThemeId(). // Dark-only themes (boocode-classic, boocode-override) always get the
// dark class regardless of resolved system mode — mirrors applyTheme().
(function () { (function () {
try { try {
var t = JSON.parse(localStorage.getItem('boocode.theme') || '{}'); var t = JSON.parse(localStorage.getItem('boocode.theme') || '{}');
var id = t.id || 'obsidian'; var id = t.id || 'ember';
var mode = t.mode || 'dark'; var mode = t.mode || 'dark';
if (mode === 'system') { if (mode === 'system') {
mode = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; mode = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
} }
// Light-only themes in dark mode → fall back to ember dark.
if ((id === 'ivory' || id === 'chalk') && mode === 'dark') { if ((id === 'ivory' || id === 'chalk') && mode === 'dark') {
id = 'obsidian'; id = 'ember';
}
// Dark-only themes: force dark class regardless of system preference.
if (id === 'boocode-classic' || id === 'boocode-override') {
mode = 'dark';
} }
document.documentElement.className = 'theme-' + id + (mode === 'dark' ? ' dark' : ''); document.documentElement.className = 'theme-' + id + (mode === 'dark' ? ' dark' : '');
} catch (e) { } catch (e) {
document.documentElement.className = 'theme-obsidian dark'; document.documentElement.className = 'theme-ember dark';
} }
})(); })();
</script> </script>

View File

@@ -13,6 +13,7 @@
"@boocode/contracts": "workspace:*", "@boocode/contracts": "workspace:*",
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource/orbitron": "^5.2.0",
"@xterm/addon-fit": "0.10.0", "@xterm/addon-fit": "0.10.0",
"@xterm/addon-search": "^0.15.0", "@xterm/addon-search": "^0.15.0",
"@xterm/addon-web-links": "0.11.0", "@xterm/addon-web-links": "0.11.0",

View File

@@ -13,6 +13,7 @@ import { useTheme } from '@/lib/theme';
import { SidebarDrawerProvider, useSidebarDrawer } from '@/hooks/useSidebarDrawer'; import { SidebarDrawerProvider, useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { RightRailDrawerProvider, useRightRailDrawer } from '@/hooks/useRightRailDrawer'; import { RightRailDrawerProvider, useRightRailDrawer } from '@/hooks/useRightRailDrawer';
import { useViewport } from '@/hooks/useViewport'; import { useViewport } from '@/hooks/useViewport';
import { ThemeFx } from '@/components/fx/ThemeFx';
function SessionRightRail() { function SessionRightRail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -73,24 +74,32 @@ function AppShell() {
// descendant — including the terminal pane — measures itself against a // descendant — including the terminal pane — measures itself against a
// height that extends behind the URL bar, and xterm allocates extra rows // height that extends behind the URL bar, and xterm allocates extra rows
// that scroll out of reach on iPhone. // 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 <html>. Content wrapper at z-10 ensures all
// UI sits above any fixed canvas regardless of theme.
return ( return (
<div className="h-dvh flex bg-background text-foreground"> <>
<ProjectSidebar /> <ThemeFx />
<MobileBackdrop /> <div className="h-dvh flex bg-background text-foreground relative z-10">
<main className="flex-1 flex flex-col min-w-0"> <ProjectSidebar />
<MobileBackdrop />
<main className="flex-1 flex flex-col min-w-0">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/project/:id" element={<Project />} />
<Route path="/session/:id" element={<Session />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</main>
<MobileRightRailBackdrop />
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/session/:id" element={<SessionRightRail />} />
<Route path="/project/:id" element={<Project />} />
<Route path="/session/:id" element={<Session />} />
<Route path="/settings" element={<Settings />} />
</Routes> </Routes>
</main> <Toaster position="bottom-right" />
<MobileRightRailBackdrop /> </div>
<Routes> </>
<Route path="/session/:id" element={<SessionRightRail />} />
</Routes>
<Toaster position="bottom-right" />
</div>
); );
} }

View File

@@ -5,6 +5,7 @@ import { Card } from '@/components/ui/card';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { THEMES, setTheme, useTheme, type ThemeId, type ThemeMode } from '@/lib/theme'; import { THEMES, setTheme, useTheme, type ThemeId, type ThemeMode } from '@/lib/theme';
import { useAnimBg, setAnimBg } from '@/lib/anim';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
// v1.9: lifted out of pages/Settings.tsx so the SettingsPane Theme tab and // 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() { export function ThemePicker() {
const { id: currentId, mode: currentMode } = useTheme(); const { id: currentId, mode: currentMode } = useTheme();
const animOn = useAnimBg();
// Track the most recent in-flight pick so the picker can show a subtle // 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. // "applying…" state on the targeted card while the PATCH is in flight.
const [pending, setPending] = useState< const [pending, setPending] = useState<
@@ -70,6 +72,33 @@ export function ThemePicker() {
</RadioGroup> </RadioGroup>
</section> </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"> <section className="space-y-3">
<h2 className="text-sm font-medium">Theme</h2> <h2 className="text-sm font-medium">Theme</h2>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3"> <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 isActive = t.id === currentId;
const isPending = pending?.kind === 'theme' && pending.id === t.id; const isPending = pending?.kind === 'theme' && pending.id === t.id;
const isLightOnly = !t.supportsDark; const isLightOnly = !t.supportsDark;
const isDarkOnly = !t.supportsLight;
return ( return (
<Card <Card
key={t.id} key={t.id}
@@ -112,6 +142,9 @@ export function ThemePicker() {
{isLightOnly && ( {isLightOnly && (
<div className="mt-2 text-xs text-muted-foreground italic">Light only</div> <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> </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;
}

53
apps/web/src/lib/anim.ts Normal file
View File

@@ -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;
}

View File

@@ -1,11 +1,13 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { api } from '@/api/client'; import { api } from '@/api/client';
// themes-v1: source of truth for the 18 presets. id and name are surfaced in // themes-v1: source of truth for the 19 presets + 3 futuristic additions.
// the picker; family groups visually; supportsDark/supportsLight reflect // id and name are surfaced in the picker; family groups visually;
// whether the corresponding selector exists in styles/themes/<id>.css; anchors // supportsDark/supportsLight reflect whether the corresponding selector exists
// are the 5 dark swatches (or the light palette for the two light-only themes) // in styles/themes/<id>.css; anchors are the 5 dark swatches (or the light
// used in the picker preview strip. // 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 = export type ThemeId =
| 'obsidian' | 'obsidian'
| 'gunmetal' | 'gunmetal'
@@ -25,7 +27,10 @@ export type ThemeId =
| 'chalk' | 'chalk'
| 'cobalt' | 'cobalt'
| 'midnight-sapphire' | 'midnight-sapphire'
| 'ember'; | 'ember'
| 'boocode-plus'
| 'boocode-classic'
| 'boocode-override';
export type ThemeMode = 'dark' | 'light' | 'system'; export type ThemeMode = 'dark' | 'light' | 'system';
@@ -75,8 +80,16 @@ export const THEMES: readonly ThemeMeta[] = [
anchors: ['#020817', '#061434', '#0c2244', '#3060a0', '#0047ab'] }, anchors: ['#020817', '#061434', '#0c2244', '#3060a0', '#0047ab'] },
{ id: 'midnight-sapphire', name: 'Midnight Sapphire', family: 'Blue', supportsDark: true, supportsLight: true, { id: 'midnight-sapphire', name: 'Midnight Sapphire', family: 'Blue', supportsDark: true, supportsLight: true,
anchors: ['#02050e', '#060c1f', '#0e1a36', '#4a6088', '#1e3a8a'] }, 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'] }, 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; ] as const;
// BooCode 2.0: orange-on-black "BooCode Ember" is the out-of-the-box signature // 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; if (typeof document === 'undefined') return;
const resolved = resolvedMode(mode); const resolved = resolvedMode(mode);
const effective = effectiveThemeId(id, resolved); 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 = document.documentElement.className =
`theme-${effective}${resolved === 'dark' ? ' dark' : ''}`; `theme-${effective}${isDark ? ' dark' : ''}`;
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ id, mode })); localStorage.setItem(STORAGE_KEY, JSON.stringify({ id, mode }));
} catch { } catch {

View File

@@ -3,6 +3,9 @@
// imports so the @font-face CSS lands before any component-tree render. // imports so the @font-face CSS lands before any component-tree render.
import '@fontsource-variable/inter'; import '@fontsource-variable/inter';
import '@fontsource-variable/jetbrains-mono'; 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 React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';

View File

@@ -2,6 +2,8 @@ import { useEffect, useState } from 'react';
import { ChevronDown, ChevronRight, Folder, FolderTree, Menu, RotateCcw } from 'lucide-react'; import { ChevronDown, ChevronRight, Folder, FolderTree, Menu, RotateCcw } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; 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 { AddProjectModal } from '@/components/AddProjectModal';
import { CreateProjectModal } from '@/components/CreateProjectModal'; import { CreateProjectModal } from '@/components/CreateProjectModal';
import { api } from '@/api/client'; import { api } from '@/api/client';
@@ -117,7 +119,20 @@ export function Home() {
</> </>
) : ( ) : (
<> <>
<h1 className="text-2xl font-semibold tracking-tight">BooCode</h1> <div className="flex flex-col items-center gap-4 pb-1">
<img
src={mascot}
alt=""
draggable={false}
className="h-28 w-auto select-none"
/>
<img
src={wordmark}
alt="BooCode"
draggable={false}
className="h-16 w-auto select-none"
/>
</div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Pick a project from the sidebar, or add another. Pick a project from the sidebar, or add another.
</p> </p>

View File

@@ -26,6 +26,13 @@
@import "./themes/cobalt.css"; @import "./themes/cobalt.css";
@import "./themes/midnight-sapphire.css"; @import "./themes/midnight-sapphire.css";
@import "./themes/ember.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 *)); @custom-variant dark (&:is(.dark *));

View File

@@ -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 <html> the
opaque page backstop (#0a0604), strip <body>'s background to transparent, and
override the SURFACE tokens to ~8286% 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;
}

View File

@@ -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, <html> is the opaque page backstop (#080b14), <body> 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.860.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;
}

View File

@@ -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;
}

View File

@@ -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 <img>, 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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 812px; 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;
}
}

View File

@@ -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;
}
}

8
pnpm-lock.yaml generated
View File

@@ -158,6 +158,9 @@ importers:
'@fontsource-variable/jetbrains-mono': '@fontsource-variable/jetbrains-mono':
specifier: ^5.2.8 specifier: ^5.2.8
version: 5.2.8 version: 5.2.8
'@fontsource/orbitron':
specifier: ^5.2.0
version: 5.2.8
'@xterm/addon-fit': '@xterm/addon-fit':
specifier: 0.10.0 specifier: 0.10.0
version: 0.10.0(@xterm/xterm@5.5.0) version: 0.10.0(@xterm/xterm@5.5.0)
@@ -839,6 +842,9 @@ packages:
'@fontsource-variable/jetbrains-mono@5.2.8': '@fontsource-variable/jetbrains-mono@5.2.8':
resolution: {integrity: sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==} resolution: {integrity: sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==}
'@fontsource/orbitron@5.2.8':
resolution: {integrity: sha512-ruzrDl5vnqNykk5DZWY0Ezj4aeFZSbCnwJTc/98ojNJHSsHhlhT2r7rwQrA5sptmF8JtB8TQTAvlfRvcV28RPw==}
'@hono/node-server@1.19.14': '@hono/node-server@1.19.14':
resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
engines: {node: '>=18.14.1'} engines: {node: '>=18.14.1'}
@@ -4705,6 +4711,8 @@ snapshots:
'@fontsource-variable/jetbrains-mono@5.2.8': {} '@fontsource-variable/jetbrains-mono@5.2.8': {}
'@fontsource/orbitron@5.2.8': {}
'@hono/node-server@1.19.14(hono@4.12.18)': '@hono/node-server@1.19.14(hono@4.12.18)':
dependencies: dependencies:
hono: 4.12.18 hono: 4.12.18