feat: refactor favicon and logo assets
This commit is contained in:
@@ -4,7 +4,7 @@ import { RiArrowDownLine } from '@remixicon/react';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
import ChatEmptyState from './ChatEmptyState';
|
||||
import MessageList from './MessageList';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { useChatScrollManager } from '@/hooks/useChatScrollManager';
|
||||
@@ -151,9 +151,7 @@ export const ChatContainer: React.FC = () => {
|
||||
className="flex flex-col h-full bg-background"
|
||||
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
||||
>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated />
|
||||
</div>
|
||||
<ChatEmptyState />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -165,7 +163,7 @@ export const ChatContainer: React.FC = () => {
|
||||
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
||||
>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated />
|
||||
<ChatEmptyState />
|
||||
</div>
|
||||
<div className="relative bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 z-10">
|
||||
<ChatInput scrollToBottom={scrollToBottom} />
|
||||
@@ -212,7 +210,7 @@ export const ChatContainer: React.FC = () => {
|
||||
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
||||
>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated />
|
||||
<ChatEmptyState />
|
||||
</div>
|
||||
<div className="relative bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 z-10">
|
||||
<ChatInput scrollToBottom={scrollToBottom} />
|
||||
|
||||
@@ -1,11 +1,53 @@
|
||||
import React from 'react';
|
||||
|
||||
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
import { TextLoop } from '@/components/ui/TextLoop';
|
||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||
|
||||
const phrases = [
|
||||
"Fix the failing tests",
|
||||
"Refactor this to be more readable",
|
||||
"Add form validation",
|
||||
"Optimize this function",
|
||||
"Write tests for this",
|
||||
"Explain how this works",
|
||||
"Add a new feature",
|
||||
"Help me debug this",
|
||||
"Review my code",
|
||||
"Simplify this logic",
|
||||
"Add error handling",
|
||||
"Create a new component",
|
||||
"Update the documentation",
|
||||
"Find the bug here",
|
||||
"Improve performance",
|
||||
"Add type definitions",
|
||||
];
|
||||
|
||||
const ChatEmptyState: React.FC = () => {
|
||||
const themeContext = useOptionalThemeSystem();
|
||||
|
||||
let isDark = true;
|
||||
if (themeContext) {
|
||||
isDark = themeContext.currentTheme.metadata.variant !== 'light';
|
||||
} else if (typeof window !== 'undefined') {
|
||||
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
// Same colors as face fill in OpenChamberLogo, but higher opacity for text readability
|
||||
const textColor = isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.4)';
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-full w-full">
|
||||
<div className="flex flex-col items-center justify-center min-h-full w-full gap-6">
|
||||
<OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated />
|
||||
<TextLoop
|
||||
className="text-body-md"
|
||||
interval={4}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{phrases.map((phrase) => (
|
||||
<span key={phrase} style={{ color: textColor }}>"{phrase}…"</span>
|
||||
))}
|
||||
</TextLoop>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
|
||||
|
||||
interface OpenChamberLogoProps {
|
||||
@@ -8,6 +8,61 @@ interface OpenChamberLogoProps {
|
||||
isAnimated?: boolean;
|
||||
}
|
||||
|
||||
// Generate grid cells for a face (4x4 grid)
|
||||
// Returns array of parallelogram paths in isometric projection
|
||||
const generateFaceGrid = (
|
||||
topLeft: { x: number; y: number },
|
||||
topRight: { x: number; y: number },
|
||||
bottomRight: { x: number; y: number },
|
||||
bottomLeft: { x: number; y: number },
|
||||
gridSize: number = 4
|
||||
) => {
|
||||
const cells: Array<{ path: string; row: number; col: number }> = [];
|
||||
|
||||
for (let row = 0; row < gridSize; row++) {
|
||||
for (let col = 0; col < gridSize; col++) {
|
||||
// Interpolate corners for this cell
|
||||
const t1 = col / gridSize;
|
||||
const t2 = (col + 1) / gridSize;
|
||||
const s1 = row / gridSize;
|
||||
const s2 = (row + 1) / gridSize;
|
||||
|
||||
// Bilinear interpolation for each corner of the cell
|
||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||
const bilinear = (tl: number, tr: number, br: number, bl: number, t: number, s: number) => {
|
||||
const top = lerp(tl, tr, t);
|
||||
const bottom = lerp(bl, br, t);
|
||||
return lerp(top, bottom, s);
|
||||
};
|
||||
|
||||
const p1 = {
|
||||
x: bilinear(topLeft.x, topRight.x, bottomRight.x, bottomLeft.x, t1, s1),
|
||||
y: bilinear(topLeft.y, topRight.y, bottomRight.y, bottomLeft.y, t1, s1),
|
||||
};
|
||||
const p2 = {
|
||||
x: bilinear(topLeft.x, topRight.x, bottomRight.x, bottomLeft.x, t2, s1),
|
||||
y: bilinear(topLeft.y, topRight.y, bottomRight.y, bottomLeft.y, t2, s1),
|
||||
};
|
||||
const p3 = {
|
||||
x: bilinear(topLeft.x, topRight.x, bottomRight.x, bottomLeft.x, t2, s2),
|
||||
y: bilinear(topLeft.y, topRight.y, bottomRight.y, bottomLeft.y, t2, s2),
|
||||
};
|
||||
const p4 = {
|
||||
x: bilinear(topLeft.x, topRight.x, bottomRight.x, bottomLeft.x, t1, s2),
|
||||
y: bilinear(topLeft.y, topRight.y, bottomRight.y, bottomLeft.y, t1, s2),
|
||||
};
|
||||
|
||||
cells.push({
|
||||
path: `M${p1.x} ${p1.y} L${p2.x} ${p2.y} L${p3.x} ${p3.y} L${p4.x} ${p4.y} Z`,
|
||||
row,
|
||||
col,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return cells;
|
||||
};
|
||||
|
||||
export const OpenChamberLogo: React.FC<OpenChamberLogoProps> = ({
|
||||
className = '',
|
||||
width = 70,
|
||||
@@ -23,67 +78,146 @@ export const OpenChamberLogo: React.FC<OpenChamberLogoProps> = ({
|
||||
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
const fillColor = isDark ? 'white' : 'black';
|
||||
const gradientId = 'shimmer-gradient';
|
||||
const strokeColor = isDark ? 'white' : 'black';
|
||||
const fillColor = isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)';
|
||||
const logoFillColor = isDark ? 'white' : 'black';
|
||||
const cellHighlightColor = isDark ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.4)';
|
||||
|
||||
|
||||
|
||||
// Isometric cube geometry (mathematically correct)
|
||||
// For true isometric: horizontal edges at ±30° from horizontal
|
||||
// cos(30°) ≈ 0.866, sin(30°) = 0.5
|
||||
// Cube edge length = 46, center at (50, 52) - larger cube, slightly lower
|
||||
const edge = 48;
|
||||
const cos30 = 0.866;
|
||||
const sin30 = 0.5;
|
||||
const centerY = 50;
|
||||
|
||||
// Key points of the isometric cube
|
||||
const top = { x: 50, y: centerY - edge }; // top vertex
|
||||
const left = { x: 50 - edge * cos30, y: centerY - edge * sin30 }; // top-left
|
||||
const right = { x: 50 + edge * cos30, y: centerY - edge * sin30 }; // top-right
|
||||
const center = { x: 50, y: centerY }; // center (front vertex of top face)
|
||||
const bottomLeft = { x: 50 - edge * cos30, y: centerY + edge * sin30 }; // bottom-left
|
||||
const bottomRight = { x: 50 + edge * cos30, y: centerY + edge * sin30 }; // bottom-right
|
||||
const bottom = { x: 50, y: centerY + edge }; // bottom vertex
|
||||
|
||||
// Isometric transformation matrix for top face
|
||||
// Maps a flat square to the isometric rhombus (top face)
|
||||
// Center of top face rhombus: average of top, left, center, right vertices
|
||||
// topFaceCenter.x = (top.x + left.x + center.x + right.x) / 4 = 50
|
||||
// topFaceCenter.y = (top.y + left.y + center.y + right.y) / 4
|
||||
const topFaceCenterY = (top.y + left.y + center.y + right.y) / 4;
|
||||
const isoMatrix = `matrix(0.866, 0.5, -0.866, 0.5, 50, ${topFaceCenterY})`;
|
||||
|
||||
// Generate grid cells for both faces
|
||||
// Left face: center -> left -> bottomLeft -> bottom
|
||||
const leftFaceCells = generateFaceGrid(left, center, bottom, bottomLeft);
|
||||
|
||||
// Right face: center -> right -> bottomRight -> bottom
|
||||
const rightFaceCells = generateFaceGrid(center, right, bottomRight, bottom);
|
||||
|
||||
// Generate random opacity values for cells (stable per component instance)
|
||||
const cellOpacities = useMemo(() => {
|
||||
const opacities: number[] = [];
|
||||
for (let i = 0; i < 32; i++) { // 16 cells per face * 2 faces
|
||||
opacities.push(0.1 + Math.random() * 0.5); // Random opacity 0.1-0.6
|
||||
}
|
||||
return opacities;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 70 70"
|
||||
viewBox="0 0 100 100"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label="OpenChamber logo"
|
||||
>
|
||||
{isAnimated && (
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor={fillColor} stopOpacity="1" />
|
||||
<stop offset="50%" stopColor={fillColor} stopOpacity="0.7" />
|
||||
<stop offset="100%" stopColor={fillColor} stopOpacity="1" />
|
||||
<animate
|
||||
attributeName="x1"
|
||||
from="-100%"
|
||||
to="200%"
|
||||
dur="4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="y1"
|
||||
from="-100%"
|
||||
to="200%"
|
||||
dur="4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="x2"
|
||||
from="0%"
|
||||
to="300%"
|
||||
dur="4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
attributeName="y2"
|
||||
from="0%"
|
||||
to="300%"
|
||||
dur="4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
)}
|
||||
{/* Left face - base fill */}
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M0 13H35V58H0V13ZM26.25 22.1957H8.75V48.701H26.25V22.1957Z"
|
||||
fill={isAnimated ? `url(#${gradientId})` : fillColor}
|
||||
d={`M${center.x} ${center.y} L${left.x} ${left.y} L${bottomLeft.x} ${bottomLeft.y} L${bottom.x} ${bottom.y} Z`}
|
||||
fill={fillColor}
|
||||
stroke={strokeColor}
|
||||
strokeWidth="2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* Left face - grid cells with varying opacity */}
|
||||
{leftFaceCells.map((cell, i) => (
|
||||
<path
|
||||
key={`left-${i}`}
|
||||
d={cell.path}
|
||||
fill={cellHighlightColor}
|
||||
opacity={cellOpacities[i]}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Right face - base fill */}
|
||||
<path
|
||||
d="M43.75 13H70V22.1957H52.5V48.701H70V57.8967H43.75V13Z"
|
||||
fill={isAnimated ? `url(#${gradientId})` : fillColor}
|
||||
d={`M${center.x} ${center.y} L${right.x} ${right.y} L${bottomRight.x} ${bottomRight.y} L${bottom.x} ${bottom.y} Z`}
|
||||
fill={fillColor}
|
||||
stroke={strokeColor}
|
||||
strokeWidth="2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* Right face - grid cells with varying opacity */}
|
||||
{rightFaceCells.map((cell, i) => (
|
||||
<path
|
||||
key={`right-${i}`}
|
||||
d={cell.path}
|
||||
fill={cellHighlightColor}
|
||||
opacity={cellOpacities[i + 16]}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Top face - open (no fill), only stroke */}
|
||||
<path
|
||||
d={`M${top.x} ${top.y} L${left.x} ${left.y} L${center.x} ${center.y} L${right.x} ${right.y} Z`}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth="2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* OpenCode logo on top face */}
|
||||
<g opacity={isAnimated ? undefined : 1}>
|
||||
{isAnimated && (
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0.4;1;0.4"
|
||||
dur="3s"
|
||||
repeatCount="indefinite"
|
||||
calcMode="spline"
|
||||
keySplines="0.4 0 0.6 1; 0.4 0 0.6 1"
|
||||
/>
|
||||
)}
|
||||
{/*
|
||||
Isometric transform for top face:
|
||||
OpenCode logo (32x40 viewBox) centered and projected to isometric plane
|
||||
*/}
|
||||
<g transform={`${isoMatrix} scale(0.75)`}>
|
||||
{/* OpenCode logo - outer frame with inner square */}
|
||||
{/* Outer frame (centered at origin, original: 0,0 to 32,40) */}
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M-16 -20 L16 -20 L16 20 L-16 20 Z M-8 -12 L-8 12 L8 12 L8 -12 Z"
|
||||
fill={logoFillColor}
|
||||
/>
|
||||
{/* Inner square */}
|
||||
<path
|
||||
d="M-8 -4 L8 -4 L8 12 L-8 12 Z"
|
||||
fill={logoFillColor}
|
||||
fillOpacity="0.4"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
motion,
|
||||
AnimatePresence,
|
||||
} from 'motion/react';
|
||||
import type {
|
||||
Transition,
|
||||
Variants,
|
||||
AnimatePresenceProps,
|
||||
} from 'motion/react';
|
||||
import { useState, useEffect, Children } from 'react';
|
||||
|
||||
export type TextLoopProps = {
|
||||
children: React.ReactNode[];
|
||||
className?: string;
|
||||
interval?: number;
|
||||
transition?: Transition;
|
||||
variants?: Variants;
|
||||
onIndexChange?: (index: number) => void;
|
||||
trigger?: boolean;
|
||||
mode?: AnimatePresenceProps['mode'];
|
||||
};
|
||||
|
||||
export function TextLoop({
|
||||
children,
|
||||
className,
|
||||
interval = 2,
|
||||
transition = { duration: 0.3 },
|
||||
variants,
|
||||
onIndexChange,
|
||||
trigger = true,
|
||||
mode = 'popLayout',
|
||||
}: TextLoopProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const items = Children.toArray(children);
|
||||
|
||||
useEffect(() => {
|
||||
if (!trigger) return;
|
||||
|
||||
const intervalMs = interval * 1000;
|
||||
const timer = setInterval(() => {
|
||||
setCurrentIndex((current) => {
|
||||
const next = (current + 1) % items.length;
|
||||
onIndexChange?.(next);
|
||||
return next;
|
||||
});
|
||||
}, intervalMs);
|
||||
return () => clearInterval(timer);
|
||||
}, [items.length, interval, onIndexChange, trigger]);
|
||||
|
||||
const motionVariants: Variants = {
|
||||
initial: { y: 20, opacity: 0 },
|
||||
animate: { y: 0, opacity: 1 },
|
||||
exit: { y: -20, opacity: 0 },
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
{/* Invisible element to maintain consistent width based on longest item */}
|
||||
<div className="invisible whitespace-nowrap">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className={i === 0 ? '' : 'absolute'}>{item}</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Animated visible element */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<AnimatePresence mode={mode} initial={false}>
|
||||
<motion.div
|
||||
key={currentIndex}
|
||||
initial='initial'
|
||||
animate='animate'
|
||||
exit='exit'
|
||||
transition={transition}
|
||||
variants={variants || motionVariants}
|
||||
className="absolute whitespace-nowrap"
|
||||
>
|
||||
{items[currentIndex]}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user