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:
@@ -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 <html>. Content wrapper at z-10 ensures all
|
||||
// UI sits above any fixed canvas regardless of theme.
|
||||
return (
|
||||
<div className="h-dvh flex bg-background text-foreground">
|
||||
<ProjectSidebar />
|
||||
<MobileBackdrop />
|
||||
<main className="flex-1 flex flex-col min-w-0">
|
||||
<>
|
||||
<ThemeFx />
|
||||
<div className="h-dvh flex bg-background text-foreground relative z-10">
|
||||
<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>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/project/:id" element={<Project />} />
|
||||
<Route path="/session/:id" element={<Session />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/session/:id" element={<SessionRightRail />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<MobileRightRailBackdrop />
|
||||
<Routes>
|
||||
<Route path="/session/:id" element={<SessionRightRail />} />
|
||||
</Routes>
|
||||
<Toaster position="bottom-right" />
|
||||
</div>
|
||||
<Toaster position="bottom-right" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Card } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { THEMES, setTheme, useTheme, type ThemeId, type ThemeMode } from '@/lib/theme';
|
||||
import { useAnimBg, setAnimBg } from '@/lib/anim';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// v1.9: lifted out of pages/Settings.tsx so the SettingsPane Theme tab and
|
||||
@@ -19,6 +20,7 @@ const MODES: { value: ThemeMode; label: string; hint: string }[] = [
|
||||
|
||||
export function ThemePicker() {
|
||||
const { id: currentId, mode: currentMode } = useTheme();
|
||||
const animOn = useAnimBg();
|
||||
// Track the most recent in-flight pick so the picker can show a subtle
|
||||
// "applying…" state on the targeted card while the PATCH is in flight.
|
||||
const [pending, setPending] = useState<
|
||||
@@ -70,6 +72,33 @@ export function ThemePicker() {
|
||||
</RadioGroup>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2 className="text-sm font-medium">Animated background</h2>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={animOn}
|
||||
onClick={() => setAnimBg(!animOn)}
|
||||
className={cn(
|
||||
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent',
|
||||
'transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
animOn ? 'bg-primary' : 'bg-input',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform duration-200',
|
||||
animOn ? 'translate-x-4' : 'translate-x-0',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Disables canvas animations and CSS effects for all themes. Persisted locally.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-medium">Theme</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
@@ -77,6 +106,7 @@ export function ThemePicker() {
|
||||
const isActive = t.id === currentId;
|
||||
const isPending = pending?.kind === 'theme' && pending.id === t.id;
|
||||
const isLightOnly = !t.supportsDark;
|
||||
const isDarkOnly = !t.supportsLight;
|
||||
return (
|
||||
<Card
|
||||
key={t.id}
|
||||
@@ -112,6 +142,9 @@ export function ThemePicker() {
|
||||
{isLightOnly && (
|
||||
<div className="mt-2 text-xs text-muted-foreground italic">Light only</div>
|
||||
)}
|
||||
{isDarkOnly && (
|
||||
<div className="mt-2 text-xs text-muted-foreground italic">Dark only</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
189
apps/web/src/components/fx/MatrixRain.tsx
Normal file
189
apps/web/src/components/fx/MatrixRain.tsx
Normal 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); // 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<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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
285
apps/web/src/components/fx/NeonField.tsx
Normal file
285
apps/web/src/components/fx/NeonField.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
/* NeonField — the BooCode Override animated background. DISTINCT from the matrix
|
||||
rain glyph engine: a "neon grid horizon". A perspective floor grid recedes to
|
||||
a vanishing point at a high horizon, scrolling toward the viewer; a slow
|
||||
brightness pulse travels up the rows; sparse data-motes drift through the
|
||||
open "sky" above the horizon. Cyan grid, magenta vanishing-point bloom, violet
|
||||
accents — boolab's booops neon palette.
|
||||
|
||||
This canvas is NET-NEW (boolab's neon was CSS, not a canvas), so it is the
|
||||
riskier of the two fields. It is kept deliberately CHEAP and LEGIBLE:
|
||||
- low primitive count: ROWS+COLS strokes + a handful of motes per frame;
|
||||
- NO per-frame shadowBlur (glow comes from a wide+thin double-stroke and the
|
||||
static CSS bloom layer in boocode-override-fx.css);
|
||||
- a full clearRect each frame (a deterministic redraw, no trail buffer);
|
||||
- dpr capped at 2, throttled to ~30fps, paused on visibilitychange;
|
||||
- the canvas is rendered at < 1 opacity so the field's brightest pixel can
|
||||
never lift the effective panel background enough to break AA text.
|
||||
Mounted by ThemeFx only when the bc-anim-on gate is on (toggle ON *and* not
|
||||
prefers-reduced-motion). */
|
||||
|
||||
const TARGET_FPS = 30;
|
||||
const FRAME_BUDGET = 1000 / TARGET_FPS;
|
||||
|
||||
const ROWS = 18; // horizontal floor lines
|
||||
const COLS = 17; // vertical lines (odd → one passes through the vanishing point)
|
||||
const HORIZON_FRAC = 0.42; // horizon height from the top
|
||||
|
||||
const CYAN = '0, 229, 255';
|
||||
const MAGENTA = '255, 45, 120';
|
||||
const VIOLET = '155, 93, 229';
|
||||
|
||||
interface Mote {
|
||||
x: number; // 0..1 of width
|
||||
y: number; // 0..1 of height (above the horizon)
|
||||
vx: number;
|
||||
vy: number;
|
||||
r: number;
|
||||
hue: string;
|
||||
phase: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
enabled?: boolean;
|
||||
/* Global field dimmer. Caps how much the field can lift any panel background,
|
||||
which is the structural AA guarantee — keep ≤ 0.6. */
|
||||
opacity?: number;
|
||||
/* Rows-per-second the grid scrolls toward the viewer. */
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
export function NeonField({ enabled = true, opacity = 0.55, speed = 0.18 }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return undefined;
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
if (!ctx) return undefined;
|
||||
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let horizonY = 0;
|
||||
let cx = 0; // vanishing-point x (centre)
|
||||
let motes: Mote[] = [];
|
||||
let scroll = 0; // grid scroll phase, fractional rows
|
||||
let wave = 0; // travelling brightness-pulse phase
|
||||
let rafId: number | null = null;
|
||||
let lastTime = performance.now();
|
||||
let resizeTimer: number | null = null;
|
||||
|
||||
const setupCanvas = () => {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
// Measure the element's own CSS box (width:100% + fixed inset:0) rather
|
||||
// than window.innerWidth — innerWidth includes the scrollbar gutter and
|
||||
// would over-size the bitmap on scrollbar-present layouts.
|
||||
width = canvas.clientWidth || window.innerWidth;
|
||||
height = canvas.clientHeight || window.innerHeight;
|
||||
horizonY = Math.round(height * HORIZON_FRAC);
|
||||
cx = width / 2;
|
||||
canvas.width = Math.max(1, Math.floor(width * dpr));
|
||||
canvas.height = Math.max(1, Math.floor(height * dpr));
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
// Sky motes: count scales gently with viewport area, hard-capped low.
|
||||
const count = Math.min(16, Math.max(6, Math.round((width * height) / 90000)));
|
||||
const hues = [MAGENTA, CYAN, VIOLET];
|
||||
motes = new Array(count);
|
||||
for (let i = 0; i < count; i++) {
|
||||
motes[i] = {
|
||||
x: Math.random(),
|
||||
y: Math.random(),
|
||||
vx: (Math.random() - 0.5) * 0.00006,
|
||||
vy: -(0.00004 + Math.random() * 0.00010), // drift upward
|
||||
r: 0.8 + Math.random() * 1.6,
|
||||
hue: hues[i % hues.length] ?? CYAN,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Perspective Y for a floor row at fractional depth d (0 = horizon, 1 =
|
||||
// bottom edge). Quadratic bunches rows toward the horizon for a perspective
|
||||
// feel without trig.
|
||||
const rowY = (d: number): number => horizonY + (height - horizonY) * d * d;
|
||||
|
||||
const drawGrid = () => {
|
||||
const floorH = height - horizonY;
|
||||
|
||||
// ── Vertical lines: fan out from the vanishing point to the bottom edge.
|
||||
// Static (the motion lives in the horizontal rows), so a single thin
|
||||
// low-alpha stroke each. Spread is widened below the bottom so the outer
|
||||
// lines leave the frame at a steep, dramatic angle.
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i < COLS; i++) {
|
||||
const t = COLS > 1 ? i / (COLS - 1) : 0.5; // 0..1
|
||||
const spread = (t - 0.5) * 2; // -1..1
|
||||
const xBottom = cx + spread * width * 0.95;
|
||||
// Dimmer toward the centre line, brighter at the dramatic outer rake.
|
||||
const a = 0.05 + Math.abs(spread) * 0.09;
|
||||
ctx.strokeStyle = `rgba(${CYAN}, ${a})`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, horizonY);
|
||||
ctx.lineTo(xBottom, height);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// ── Horizontal rows: scroll toward the viewer. Each row gets a wide faint
|
||||
// pass (the glow) and a thin bright pass (the core) — a cheap neon bloom
|
||||
// with no shadowBlur. A travelling sine pulse rides up the rows.
|
||||
for (let i = 0; i < ROWS; i++) {
|
||||
let d = (i + scroll) % ROWS / ROWS; // 0..1, wraps as it scrolls
|
||||
if (d <= 0) d += 1 / ROWS;
|
||||
const y = rowY(d);
|
||||
if (y <= horizonY || y > height + 1) continue;
|
||||
|
||||
// Depth fade: near rows (large d) are brighter than far rows.
|
||||
const depth = 0.18 + d * 0.55;
|
||||
// Travelling brightness pulse along the floor.
|
||||
const pulse = 0.5 + 0.5 * Math.sin((d - wave) * Math.PI * 2);
|
||||
const a = Math.min(0.34, depth * (0.62 + pulse * 0.5));
|
||||
// Width of the row segment narrows toward the horizon (perspective).
|
||||
const halfW = width * (0.5 + d * 0.5);
|
||||
|
||||
// Glow pass (wide, faint, violet-shifted).
|
||||
ctx.lineWidth = 2.4;
|
||||
ctx.strokeStyle = `rgba(${VIOLET}, ${a * 0.4})`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx - halfW, y);
|
||||
ctx.lineTo(cx + halfW, y);
|
||||
ctx.stroke();
|
||||
|
||||
// Core pass (thin, bright, cyan).
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = `rgba(${CYAN}, ${a})`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx - halfW, y);
|
||||
ctx.lineTo(cx + halfW, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// ── Horizon line + vanishing-point magenta bloom. The horizon is the
|
||||
// theme's signature glow band; kept low-alpha and wide so it reads as
|
||||
// bloom, not a hard bright bar behind text.
|
||||
const grad = ctx.createLinearGradient(0, horizonY - floorH * 0.06, 0, horizonY + 2);
|
||||
grad.addColorStop(0, `rgba(${MAGENTA}, 0)`);
|
||||
grad.addColorStop(1, `rgba(${MAGENTA}, 0.10)`);
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, horizonY - floorH * 0.06, width, floorH * 0.06 + 2);
|
||||
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = `rgba(${MAGENTA}, 0.22)`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, horizonY);
|
||||
ctx.lineTo(width, horizonY);
|
||||
ctx.stroke();
|
||||
|
||||
// Radial magenta pool at the vanishing point.
|
||||
const vp = ctx.createRadialGradient(cx, horizonY, 0, cx, horizonY, width * 0.22);
|
||||
vp.addColorStop(0, `rgba(${MAGENTA}, 0.16)`);
|
||||
vp.addColorStop(1, `rgba(${MAGENTA}, 0)`);
|
||||
ctx.fillStyle = vp;
|
||||
ctx.fillRect(0, Math.max(0, horizonY - width * 0.22), width, width * 0.44);
|
||||
};
|
||||
|
||||
const drawMotes = (dt: number) => {
|
||||
for (let i = 0; i < motes.length; i++) {
|
||||
const m = motes[i];
|
||||
if (!m) continue;
|
||||
m.x += m.vx * dt;
|
||||
m.y += m.vy * dt;
|
||||
m.phase += dt * 0.0014;
|
||||
// Wrap within the sky band (above the horizon).
|
||||
if (m.y < -0.02) {
|
||||
m.y = 1;
|
||||
m.x = Math.random();
|
||||
}
|
||||
if (m.x < -0.02) m.x = 1.02;
|
||||
else if (m.x > 1.02) m.x = -0.02;
|
||||
|
||||
const px = m.x * width;
|
||||
const py = m.y * horizonY; // confine to the sky region
|
||||
const twinkle = 0.45 + 0.55 * (0.5 + 0.5 * Math.sin(m.phase));
|
||||
const a = Math.min(0.5, twinkle * 0.5);
|
||||
const g = ctx.createRadialGradient(px, py, 0, px, py, m.r * 3.2);
|
||||
g.addColorStop(0, `rgba(${m.hue}, ${a})`);
|
||||
g.addColorStop(1, `rgba(${m.hue}, 0)`);
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, m.r * 3.2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
};
|
||||
|
||||
const draw = (dt: number) => {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
const effSpeed = Math.max(0, speed);
|
||||
scroll = (scroll + (dt / 1000) * effSpeed * ROWS) % ROWS;
|
||||
wave = (wave + dt / 1000 / 6) % 1; // one pulse sweep every ~6s
|
||||
drawGrid();
|
||||
drawMotes(dt);
|
||||
};
|
||||
|
||||
const loop = (now: number) => {
|
||||
rafId = window.requestAnimationFrame(loop);
|
||||
if (document.hidden) return;
|
||||
const elapsed = now - lastTime;
|
||||
if (elapsed < FRAME_BUDGET) return;
|
||||
lastTime = now - (elapsed % FRAME_BUDGET);
|
||||
draw(Math.min(elapsed, 100)); // clamp dt so a backgrounded tab can't jump
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (rafId != null) return;
|
||||
lastTime = performance.now();
|
||||
rafId = window.requestAnimationFrame(loop);
|
||||
};
|
||||
const stop = () => {
|
||||
if (rafId != null) {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.hidden) stop();
|
||||
else start();
|
||||
};
|
||||
const onResize = () => {
|
||||
if (resizeTimer) window.clearTimeout(resizeTimer);
|
||||
resizeTimer = window.setTimeout(setupCanvas, 150);
|
||||
};
|
||||
|
||||
setupCanvas();
|
||||
if (!document.hidden) start();
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
if (resizeTimer) window.clearTimeout(resizeTimer);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
}, [enabled, speed]);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
zIndex: 0,
|
||||
pointerEvents: 'none',
|
||||
display: 'block',
|
||||
opacity,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
51
apps/web/src/components/fx/ThemeFx.tsx
Normal file
51
apps/web/src/components/fx/ThemeFx.tsx
Normal 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
53
apps/web/src/lib/anim.ts
Normal 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;
|
||||
}
|
||||
@@ -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/<id>.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/<id>.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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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">
|
||||
Pick a project from the sidebar, or add another.
|
||||
</p>
|
||||
|
||||
@@ -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 *));
|
||||
|
||||
|
||||
57
apps/web/src/styles/themes/boocode-classic.css
Normal file
57
apps/web/src/styles/themes/boocode-classic.css
Normal 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 ~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;
|
||||
}
|
||||
59
apps/web/src/styles/themes/boocode-override.css
Normal file
59
apps/web/src/styles/themes/boocode-override.css
Normal 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.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;
|
||||
}
|
||||
81
apps/web/src/styles/themes/boocode-plus.css
Normal file
81
apps/web/src/styles/themes/boocode-plus.css
Normal 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;
|
||||
}
|
||||
268
apps/web/src/styles/themes/effects/boocode-classic-fx.css
Normal file
268
apps/web/src/styles/themes/effects/boocode-classic-fx.css
Normal 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;
|
||||
}
|
||||
}
|
||||
288
apps/web/src/styles/themes/effects/boocode-override-fx.css
Normal file
288
apps/web/src/styles/themes/effects/boocode-override-fx.css
Normal 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;
|
||||
}
|
||||
}
|
||||
205
apps/web/src/styles/themes/effects/boocode-plus-fx.css
Normal file
205
apps/web/src/styles/themes/effects/boocode-plus-fx.css
Normal 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 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;
|
||||
}
|
||||
}
|
||||
153
apps/web/src/styles/themes/ember-polish.css
Normal file
153
apps/web/src/styles/themes/ember-polish.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user