feat: refactor favicon and logo assets

This commit is contained in:
Bohdan Triapitsyn
2025-12-21 22:59:06 +02:00
parent 7699aab123
commit 4ab4616dea
31 changed files with 1088 additions and 195 deletions
@@ -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>
);
};
+181 -47
View File
@@ -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>
);
}