Skip to content

Commit 5817433

Browse files
committed
feat(app): color theory
1 parent 5a8a0af commit 5817433

File tree

11 files changed

+910
-0
lines changed

11 files changed

+910
-0
lines changed

public/apps/apps.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -838,6 +838,14 @@
838838
"icon": "GraduationCapIcon",
839839
"order": 6,
840840
"apps": [
841+
{
842+
"slug": "color-theory",
843+
"to": "/apps/color-theory",
844+
"title": "Chromatology",
845+
"description": "Interactive exploration of color harmony, models, and perception.",
846+
"icon": "PaletteIcon",
847+
"created_at": "2026-01-20T12:00:00+03:00"
848+
},
841849
{
842850
"slug": "js-masterclass",
843851
"to": "/apps/js-masterclass",
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import React, { useState } from 'react';
2+
import { motion } from 'framer-motion';
3+
import { PaletteIcon, SwatchesIcon, EyeIcon, GameControllerIcon, ArrowLeftIcon, BookOpenIcon } from '@phosphor-icons/react';
4+
import { Link } from 'react-router-dom';
5+
6+
import ColorWheel from './components/ColorWheel';
7+
import HarmonyExplorer from './components/HarmonyExplorer';
8+
import PerceptionPlayground from './components/PerceptionPlayground';
9+
import ColorGames from './components/ColorGames';
10+
import LearnSection from './components/LearnSection';
11+
12+
const sections = [
13+
{ id: 'learn', label: 'Learn', icon: BookOpenIcon, component: LearnSection, color: '#FF5C5C' }, // Red
14+
{ id: 'wheel', label: 'The Wheel', icon: PaletteIcon, component: ColorWheel, color: '#FFBD4A' }, // Yellow
15+
{ id: 'harmony', label: 'Harmonies', icon: SwatchesIcon, component: HarmonyExplorer, color: '#5271FF' }, // Blue
16+
{ id: 'perception', label: 'Perception', icon: EyeIcon, component: PerceptionPlayground, color: '#00C896' }, // Green
17+
{ id: 'games', label: 'Games', icon: GameControllerIcon, component: ColorGames, color: '#9D4EDD' }, // Purple
18+
];
19+
20+
const ColorTheoryApp = () => {
21+
const [activeSection, setActiveSection] = useState('learn');
22+
23+
return (
24+
<div className="flex h-screen bg-[#f4f4f0] text-[#1a1a1a] font-nunito overflow-hidden selection:bg-[#1a1a1a] selection:text-[#f4f4f0]">
25+
26+
{/* Sidebar - Swiss Style */}
27+
<aside className="w-20 lg:w-72 flex flex-col border-r-4 border-[#1a1a1a] bg-white shrink-0 z-20">
28+
29+
{/* Brand Header */}
30+
<div className="p-6 border-b-4 border-[#1a1a1a] flex items-center justify-between bg-[#1a1a1a] text-[#f4f4f0]">
31+
<h1 className="hidden lg:block text-3xl font-normal uppercase tracking-tighter font-instr-serif">
32+
Chromatology
33+
</h1>
34+
<Link to="/apps" className="p-2 hover:bg-white/20 rounded-full transition-colors" title="Exit">
35+
<ArrowLeftIcon size={24} weight="bold" />
36+
</Link>
37+
</div>
38+
39+
{/* Navigation */}
40+
<nav className="flex-1 overflow-y-auto py-6 flex flex-col gap-2 px-3">
41+
{sections.map((section) => {
42+
const isActive = activeSection === section.id;
43+
const Icon = section.icon;
44+
45+
return (
46+
<button
47+
key={section.id}
48+
onClick={() => setActiveSection(section.id)}
49+
className={`
50+
relative group flex items-center gap-4 p-4 rounded-xl transition-all duration-200 border-2
51+
${isActive
52+
? 'bg-[#f4f4f0] border-[#1a1a1a] shadow-[4px_4px_0px_0px_#1a1a1a] translate-x-[-2px] translate-y-[-2px]'
53+
: 'bg-transparent border-transparent hover:bg-black/5'
54+
}
55+
`}
56+
>
57+
<div
58+
className="w-10 h-10 rounded-lg flex items-center justify-center border-2 border-[#1a1a1a] shadow-sm transition-transform group-hover:scale-110"
59+
style={{ backgroundColor: section.color }}
60+
>
61+
<Icon size={20} weight="fill" className="text-white mix-blend-multiply" />
62+
</div>
63+
<span className={`hidden lg:block font-bold text-lg tracking-tight ${isActive ? 'text-[#1a1a1a]' : 'text-[#666]'}`}>
64+
{section.label}
65+
</span>
66+
</button>
67+
)
68+
})}
69+
</nav>
70+
71+
{/* Footer info */}
72+
<div className="p-6 border-t-4 border-[#1a1a1a] hidden lg:block">
73+
<p className="text-xs font-mono text-gray-500">
74+
DESIGN: SWISS / BAUHAUS<br/>
75+
VER: 2.0.0
76+
</p>
77+
</div>
78+
</aside>
79+
80+
{/* Main Content Area */}
81+
<main className="flex-1 relative overflow-hidden bg-[#f4f4f0]">
82+
83+
{/* Decorative Grid Background */}
84+
<div className="absolute inset-0 opacity-[0.03] pointer-events-none"
85+
style={{
86+
backgroundImage: 'radial-gradient(#000 1px, transparent 1px)',
87+
backgroundSize: '20px 20px'
88+
}}
89+
/>
90+
91+
{/* Content Render */}
92+
<div className="h-full w-full relative z-10">
93+
{/* Note: Removed AnimatePresence for tab switching stability based on user feedback.
94+
Instant switching is often preferred in tool-like apps.
95+
*/}
96+
<div className="h-full w-full">
97+
{(() => {
98+
const Component = sections.find(s => s.id === activeSection)?.component;
99+
if (!Component) return null;
100+
return (
101+
<motion.div
102+
key={activeSection}
103+
initial={{ opacity: 0, x: 20 }}
104+
animate={{ opacity: 1, x: 0 }}
105+
transition={{ duration: 0.3, ease: 'easeOut' }}
106+
className="h-full w-full"
107+
>
108+
<Component />
109+
</motion.div>
110+
);
111+
})()}
112+
</div>
113+
</div>
114+
115+
</main>
116+
117+
</div>
118+
);
119+
};
120+
121+
export default ColorTheoryApp;
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import React, { useState, useEffect, useCallback } from 'react';
2+
import { motion, AnimatePresence } from 'framer-motion';
3+
import { hslToHex } from '../utils/colorUtils';
4+
import { CheckCircle, XCircle } from '@phosphor-icons/react';
5+
6+
const ColorGames = () => {
7+
const [targetColor, setTargetColor] = useState(null);
8+
const [options, setOptions] = useState([]);
9+
const [score, setScore] = useState(0);
10+
const [feedback, setFeedback] = useState(null); // 'correct' | 'wrong'
11+
12+
const generateColor = () => {
13+
return {
14+
h: Math.floor(Math.random() * 360),
15+
s: 70 + Math.floor(Math.random() * 30), // Keep it vibrant
16+
l: 40 + Math.floor(Math.random() * 20)
17+
};
18+
};
19+
20+
const startNewRound = useCallback(() => {
21+
const correct = generateColor();
22+
setTargetColor(correct);
23+
24+
// Generate 3 distractors
25+
const distractors = [1, 2, 3].map(() => {
26+
const c = generateColor();
27+
// ensure distinct hues
28+
return c;
29+
});
30+
31+
const allOptions = [correct, ...distractors].sort(() => Math.random() - 0.5);
32+
setOptions(allOptions);
33+
setFeedback(null);
34+
}, []);
35+
36+
useEffect(() => {
37+
startNewRound();
38+
}, [startNewRound]);
39+
const handleGuess = (option) => {
40+
if (feedback) return; // Prevent double guessing
41+
42+
if (option === targetColor) {
43+
setScore(s => s + 10);
44+
setFeedback('correct');
45+
setTimeout(startNewRound, 1000);
46+
} else {
47+
setScore(s => Math.max(0, s - 5));
48+
setFeedback('wrong');
49+
setTimeout(startNewRound, 1000);
50+
}
51+
};
52+
53+
if (!targetColor) return null;
54+
55+
return (
56+
<div className="p-8 min-h-full flex flex-col items-center justify-center font-nunito bg-[#f4f4f0]">
57+
<div className="w-full max-w-2xl bg-white border-2 border-[#1a1a1a] rounded-3xl p-8 shadow-[12px_12px_0px_0px_#1a1a1a] relative overflow-hidden">
58+
59+
{/* Header */}
60+
<div className="flex justify-between items-center mb-8">
61+
<h2 className="text-4xl font-normal uppercase tracking-tighter font-instr-serif text-[#1a1a1a]">Hex Hunter</h2>
62+
<div className="bg-[#1a1a1a] text-white px-6 py-2 rounded-lg font-mono font-bold text-xl border-2 border-transparent">
63+
SCORE: {score}
64+
</div>
65+
</div>
66+
{/* Game Area */}
67+
<div className="flex flex-col items-center gap-10">
68+
69+
<motion.div
70+
key={hslToHex(targetColor.h, targetColor.s, targetColor.l)}
71+
initial={{ scale: 0.8, opacity: 0 }}
72+
animate={{ scale: 1, opacity: 1 }}
73+
className="w-48 h-48 rounded-full border-4 border-[#1a1a1a] shadow-xl flex items-center justify-center text-white/20 font-black text-6xl select-none"
74+
style={{ backgroundColor: hslToHex(targetColor.h, targetColor.s, targetColor.l) }}
75+
>
76+
?
77+
</motion.div>
78+
79+
<p className="text-[#666] font-bold uppercase tracking-widest text-sm">Which HEX code matches this color?</p>
80+
81+
<div className="grid grid-cols-2 gap-4 w-full">
82+
{options.map((opt, i) => {
83+
const hex = hslToHex(opt.h, opt.s, opt.l);
84+
const isTarget = opt === targetColor;
85+
86+
return (
87+
<button
88+
key={i}
89+
onClick={() => handleGuess(opt)}
90+
disabled={!!feedback}
91+
className={`p-6 rounded-xl font-mono text-xl font-bold transition-all transform border-2 border-[#1a1a1a]
92+
${feedback && isTarget ? 'bg-[#00C896] text-white' : ''}
93+
${feedback === 'wrong' && !isTarget ? 'opacity-30' : ''}
94+
${!feedback ? 'bg-white hover:bg-[#1a1a1a] hover:text-white hover:shadow-[4px_4px_0px_0px_#666]' : ''}
95+
`}
96+
>
97+
{hex.toUpperCase()}
98+
</button>
99+
)
100+
})}
101+
</div>
102+
</div>
103+
{/* Feedback Overlay */}
104+
<AnimatePresence>
105+
{feedback && (
106+
<motion.div
107+
initial={{ opacity: 0 }}
108+
animate={{ opacity: 1 }}
109+
exit={{ opacity: 0 }}
110+
className="absolute inset-0 z-10 flex items-center justify-center bg-black/60 backdrop-blur-sm"
111+
>
112+
<motion.div
113+
initial={{ scale: 0.5 }}
114+
animate={{ scale: 1 }}
115+
className={`p-8 rounded-full ${feedback === 'correct' ? 'bg-emerald-500' : 'bg-rose-500'} text-white shadow-2xl`}
116+
>
117+
{feedback === 'correct' ? <CheckCircle size={64} weight="fill" /> : <XCircle size={64} weight="fill" />}
118+
</motion.div>
119+
</motion.div>
120+
)}
121+
</AnimatePresence>
122+
123+
</div>
124+
</div>
125+
);
126+
};
127+
128+
export default ColorGames;

0 commit comments

Comments
 (0)