create-fluxstack 1.18.0 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +132 -0
- package/app/client/src/App.tsx +7 -7
- package/app/client/src/components/AppLayout.tsx +60 -23
- package/app/client/src/components/ColorWheel.tsx +195 -0
- package/app/client/src/components/DemoPage.tsx +5 -3
- package/app/client/src/components/LiveUploadWidget.tsx +1 -1
- package/app/client/src/components/ThemePicker.tsx +307 -0
- package/app/client/src/config/theme.config.ts +127 -0
- package/app/client/src/hooks/useThemeClock.ts +66 -0
- package/app/client/src/index.css +193 -0
- package/app/client/src/lib/theme-clock.ts +201 -0
- package/app/client/src/live/AuthDemo.tsx +9 -9
- package/app/client/src/live/CounterDemo.tsx +10 -10
- package/app/client/src/live/FormDemo.tsx +8 -8
- package/app/client/src/live/PingPongDemo.tsx +10 -10
- package/app/client/src/live/RoomChatDemo.tsx +10 -10
- package/app/client/src/live/SharedCounterDemo.tsx +5 -5
- package/app/client/src/pages/ApiTestPage.tsx +5 -5
- package/app/client/src/pages/HomePage.tsx +12 -12
- package/app/server/index.ts +8 -0
- package/app/server/live/auto-generated-components.ts +1 -1
- package/app/server/live/rooms/ChatRoom.ts +13 -8
- package/app/server/routes/index.ts +20 -10
- package/core/build/index.ts +1 -1
- package/core/cli/command-registry.ts +1 -1
- package/core/cli/commands/build.ts +25 -6
- package/core/cli/commands/plugin-deps.ts +1 -2
- package/core/cli/generators/plugin.ts +433 -581
- package/core/framework/server.ts +34 -8
- package/core/index.ts +6 -5
- package/core/plugins/index.ts +71 -199
- package/core/plugins/types.ts +76 -461
- package/core/server/index.ts +1 -1
- package/core/utils/logger/startup-banner.ts +26 -4
- package/core/utils/version.ts +6 -6
- package/create-fluxstack.ts +216 -107
- package/package.json +108 -107
- package/tsconfig.json +2 -1
- package/app/client/.live-stubs/LiveAdminPanel.js +0 -15
- package/app/client/.live-stubs/LiveCounter.js +0 -9
- package/app/client/.live-stubs/LiveForm.js +0 -11
- package/app/client/.live-stubs/LiveLocalCounter.js +0 -8
- package/app/client/.live-stubs/LivePingPong.js +0 -10
- package/app/client/.live-stubs/LiveRoomChat.js +0 -11
- package/app/client/.live-stubs/LiveSharedCounter.js +0 -10
- package/app/client/.live-stubs/LiveUpload.js +0 -15
- package/core/plugins/config.ts +0 -356
- package/core/plugins/dependency-manager.ts +0 -481
- package/core/plugins/discovery.ts +0 -379
- package/core/plugins/executor.ts +0 -353
- package/core/plugins/manager.ts +0 -645
- package/core/plugins/module-resolver.ts +0 -227
- package/core/plugins/registry.ts +0 -913
- package/vitest.config.live.ts +0 -69
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ThemePicker — floating panel with interactive color wheel
|
|
3
|
+
*/
|
|
4
|
+
import { useState, useCallback, useRef } from 'react'
|
|
5
|
+
import { buildPaletteFromHues, applyPalette, type ColorPalette } from '../lib/theme-clock'
|
|
6
|
+
import { ColorWheel, generateHuesFromMode, type HarmonyMode } from './ColorWheel'
|
|
7
|
+
import { createPortal } from 'react-dom'
|
|
8
|
+
|
|
9
|
+
interface ThemePickerProps {
|
|
10
|
+
palette: ColorPalette
|
|
11
|
+
onOverride: (palette: ColorPalette | null) => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const harmonyModes: { mode: HarmonyMode; label: string; icon: string }[] = [
|
|
15
|
+
{ mode: 'analogous', label: 'Análogo', icon: '◐' },
|
|
16
|
+
{ mode: 'complementary', label: 'Complementar', icon: '◑' },
|
|
17
|
+
{ mode: 'triadic', label: 'Triádico', icon: '△' },
|
|
18
|
+
{ mode: 'split-complementary', label: 'Split', icon: '⋔' },
|
|
19
|
+
{ mode: 'square', label: 'Quadrado', icon: '◻' },
|
|
20
|
+
{ mode: 'custom', label: 'Custom', icon: '✦' },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
function getHueName(hue: number): string {
|
|
24
|
+
const h = ((hue % 360) + 360) % 360
|
|
25
|
+
if (h < 15 || h >= 345) return 'red'
|
|
26
|
+
if (h < 45) return 'orange'
|
|
27
|
+
if (h < 75) return 'yellow'
|
|
28
|
+
if (h < 105) return 'lime'
|
|
29
|
+
if (h < 135) return 'green'
|
|
30
|
+
if (h < 165) return 'teal'
|
|
31
|
+
if (h < 195) return 'cyan'
|
|
32
|
+
if (h < 225) return 'blue'
|
|
33
|
+
if (h < 255) return 'indigo'
|
|
34
|
+
if (h < 285) return 'purple'
|
|
35
|
+
if (h < 315) return 'pink'
|
|
36
|
+
return 'magenta'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function huesToPalette(hues: number[]): ColorPalette {
|
|
40
|
+
return buildPaletteFromHues(hues[0], 'midday', {
|
|
41
|
+
secondary: hues[1],
|
|
42
|
+
tertiary: hues[2],
|
|
43
|
+
complement: hues[3],
|
|
44
|
+
accent: hues[4],
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function ConfigModal({ config, palette, onClose }: { config: string; palette: ColorPalette; onClose: () => void }) {
|
|
49
|
+
const [copied, setCopied] = useState(false)
|
|
50
|
+
|
|
51
|
+
const handleCopy = () => {
|
|
52
|
+
navigator.clipboard.writeText(config)
|
|
53
|
+
setCopied(true)
|
|
54
|
+
setTimeout(() => setCopied(false), 2000)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return createPortal(
|
|
58
|
+
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4" onClick={onClose}>
|
|
59
|
+
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
|
60
|
+
<div
|
|
61
|
+
className="relative w-full max-w-lg rounded-2xl p-6 shadow-2xl"
|
|
62
|
+
style={{ backgroundColor: `oklch(12% 0.015 ${palette.baseHue})`, border: `1px solid ${palette.border}` }}
|
|
63
|
+
onClick={e => e.stopPropagation()}
|
|
64
|
+
>
|
|
65
|
+
<h2 className="text-lg font-bold text-white mb-1">🎨 Fixar Tema</h2>
|
|
66
|
+
<p className="text-sm text-gray-400 mb-4">
|
|
67
|
+
Cole este código em <code className="text-theme text-xs px-1.5 py-0.5 rounded bg-white/5">app/client/src/config/theme.config.ts</code>
|
|
68
|
+
</p>
|
|
69
|
+
|
|
70
|
+
<pre
|
|
71
|
+
className="text-sm font-mono p-4 rounded-xl overflow-x-auto mb-4 leading-relaxed"
|
|
72
|
+
style={{ backgroundColor: 'rgba(0,0,0,0.4)', border: `1px solid ${palette.border}` }}
|
|
73
|
+
>
|
|
74
|
+
<code className="text-gray-300">{config}</code>
|
|
75
|
+
</pre>
|
|
76
|
+
|
|
77
|
+
<div className="flex items-center gap-2 text-xs text-gray-500 mb-4 p-3 rounded-xl" style={{ backgroundColor: palette.bgAccent }}>
|
|
78
|
+
<span>💡</span>
|
|
79
|
+
<span>Após colar, o Vite hot reload aplica o tema automaticamente. Para esconder o picker em produção, mantenha <code className="text-theme">showPicker: false</code>.</span>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div className="flex gap-2">
|
|
83
|
+
<button
|
|
84
|
+
onClick={handleCopy}
|
|
85
|
+
className="flex-1 py-2.5 rounded-xl text-sm font-medium transition-all"
|
|
86
|
+
style={copied
|
|
87
|
+
? { backgroundColor: 'oklch(70% 0.2 150)', color: 'white' }
|
|
88
|
+
: { backgroundColor: palette.primary, color: 'white' }
|
|
89
|
+
}
|
|
90
|
+
>
|
|
91
|
+
{copied ? '✓ Copiado!' : '📋 Copiar Código'}
|
|
92
|
+
</button>
|
|
93
|
+
<button
|
|
94
|
+
onClick={onClose}
|
|
95
|
+
className="px-4 py-2.5 rounded-xl text-sm font-medium btn-theme-ghost"
|
|
96
|
+
>
|
|
97
|
+
Fechar
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</div>,
|
|
102
|
+
document.body
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function ThemePicker({ palette, onOverride }: ThemePickerProps) {
|
|
107
|
+
const [open, setOpen] = useState(false)
|
|
108
|
+
const [harmonyMode, setHarmonyMode] = useState<HarmonyMode>('analogous')
|
|
109
|
+
const [hues, setHues] = useState<number[]>(() => generateHuesFromMode(palette.baseHue, 'analogous'))
|
|
110
|
+
const [isManual, setIsManual] = useState(false)
|
|
111
|
+
const [showConfigModal, setShowConfigModal] = useState(false)
|
|
112
|
+
const configRef = useRef('')
|
|
113
|
+
|
|
114
|
+
const handleHueChange = useCallback((index: number, hue: number) => {
|
|
115
|
+
setIsManual(true)
|
|
116
|
+
setHues(prev => {
|
|
117
|
+
const next = [...prev]
|
|
118
|
+
next[index] = hue
|
|
119
|
+
return next
|
|
120
|
+
})
|
|
121
|
+
// Build palette from current hues with the new one
|
|
122
|
+
const updated = [...hues]
|
|
123
|
+
updated[index] = hue
|
|
124
|
+
// Pad to 5 hues
|
|
125
|
+
while (updated.length < 5) updated.push(updated[0])
|
|
126
|
+
const p = huesToPalette(updated)
|
|
127
|
+
applyPalette(p)
|
|
128
|
+
onOverride(p)
|
|
129
|
+
}, [hues, onOverride])
|
|
130
|
+
|
|
131
|
+
const handleModeChange = useCallback((mode: HarmonyMode) => {
|
|
132
|
+
setHarmonyMode(mode)
|
|
133
|
+
const baseHue = hues[0] ?? palette.baseHue
|
|
134
|
+
const newHues = generateHuesFromMode(baseHue, mode)
|
|
135
|
+
// Pad to 5 for custom
|
|
136
|
+
while (newHues.length < 5) newHues.push(baseHue)
|
|
137
|
+
setHues(newHues)
|
|
138
|
+
if (isManual) {
|
|
139
|
+
const p = huesToPalette(newHues)
|
|
140
|
+
applyPalette(p)
|
|
141
|
+
onOverride(p)
|
|
142
|
+
}
|
|
143
|
+
}, [hues, palette.baseHue, isManual, onOverride])
|
|
144
|
+
|
|
145
|
+
const handleAuto = useCallback(() => {
|
|
146
|
+
setIsManual(false)
|
|
147
|
+
onOverride(null)
|
|
148
|
+
}, [onOverride])
|
|
149
|
+
|
|
150
|
+
const showConfig = useCallback(() => {
|
|
151
|
+
const h = hues.map(h => Math.round(h))
|
|
152
|
+
// Calculate relative offset from primary, normalized to -180..+180
|
|
153
|
+
const offset = (target: number) => {
|
|
154
|
+
let d = ((target - h[0]) % 360 + 540) % 360 - 180
|
|
155
|
+
return d >= 0 ? `+${d}°` : `${d}°`
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
configRef.current = harmonyMode === 'custom'
|
|
159
|
+
? `export const themeConfig: ThemeConfig = {
|
|
160
|
+
mode: 'custom',
|
|
161
|
+
palette: {
|
|
162
|
+
primary: ${h[0]}, // ${getHueName(h[0])} (base)
|
|
163
|
+
secondary: ${h[1]}, // ${getHueName(h[1])} (${offset(h[1])} from primary)
|
|
164
|
+
tertiary: ${h[2]}, // ${getHueName(h[2])} (${offset(h[2])} from primary)
|
|
165
|
+
complement: ${h[3]}, // ${getHueName(h[3])} (${offset(h[3])} from primary)
|
|
166
|
+
accent: ${h[4]}, // ${getHueName(h[4])} (${offset(h[4])} from primary)
|
|
167
|
+
},
|
|
168
|
+
showPicker: false,
|
|
169
|
+
}`
|
|
170
|
+
: `export const themeConfig: ThemeConfig = {
|
|
171
|
+
mode: 'fixed',
|
|
172
|
+
hue: ${h[0]}, // ${getHueName(h[0])}
|
|
173
|
+
showPicker: false,
|
|
174
|
+
}`
|
|
175
|
+
setShowConfigModal(true)
|
|
176
|
+
}, [hues, harmonyMode])
|
|
177
|
+
|
|
178
|
+
const swatchLabels = ['Primary', 'Secondary', 'Tertiary', 'Complement', 'Accent']
|
|
179
|
+
const displayHues = [...hues]
|
|
180
|
+
while (displayHues.length < 5) displayHues.push(displayHues[0] ?? 0)
|
|
181
|
+
const displayPalette = isManual ? huesToPalette(displayHues) : palette
|
|
182
|
+
|
|
183
|
+
if (!open) {
|
|
184
|
+
return (
|
|
185
|
+
<button
|
|
186
|
+
onClick={() => setOpen(true)}
|
|
187
|
+
className="fixed bottom-4 right-4 z-50 w-12 h-12 rounded-full shadow-lg flex items-center justify-center text-white text-lg transition-all hover:scale-110 border border-white/10"
|
|
188
|
+
style={{ background: palette.gradientPrimary }}
|
|
189
|
+
title="Theme Picker"
|
|
190
|
+
>
|
|
191
|
+
🎨
|
|
192
|
+
</button>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<div
|
|
198
|
+
className="fixed bottom-4 right-4 z-50 rounded-2xl shadow-2xl overflow-hidden"
|
|
199
|
+
style={{
|
|
200
|
+
backgroundColor: `oklch(10% 0.015 ${displayPalette.baseHue})`,
|
|
201
|
+
border: `1px solid ${displayPalette.border}`,
|
|
202
|
+
width: '320px',
|
|
203
|
+
}}
|
|
204
|
+
>
|
|
205
|
+
{/* Header */}
|
|
206
|
+
<div className="flex items-center justify-between px-4 py-2.5"
|
|
207
|
+
style={{ borderBottom: `1px solid ${displayPalette.border}` }}
|
|
208
|
+
>
|
|
209
|
+
<div className="flex items-center gap-2">
|
|
210
|
+
<span>🎨</span>
|
|
211
|
+
<span className="text-white text-sm font-semibold">Color Wheel</span>
|
|
212
|
+
{isManual && (
|
|
213
|
+
<span className="text-[10px] px-1.5 py-0.5 rounded-full"
|
|
214
|
+
style={{ backgroundColor: displayPalette.primaryMuted, color: displayPalette.textPrimary }}
|
|
215
|
+
>
|
|
216
|
+
{harmonyMode === 'custom' ? 'custom' : Math.round(hues[0]) + '°'}
|
|
217
|
+
</span>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
<button onClick={() => setOpen(false)}
|
|
221
|
+
className="text-gray-500 hover:text-white transition-colors text-lg leading-none w-6 h-6 flex items-center justify-center rounded-full hover:bg-white/10"
|
|
222
|
+
>×</button>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
{/* Color Wheel */}
|
|
226
|
+
<div className="flex justify-center py-3">
|
|
227
|
+
<ColorWheel
|
|
228
|
+
hues={displayHues}
|
|
229
|
+
mode={harmonyMode}
|
|
230
|
+
size={210}
|
|
231
|
+
onChange={handleHueChange}
|
|
232
|
+
/>
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
{/* Harmony Mode Selector */}
|
|
236
|
+
<div className="px-3 pb-2">
|
|
237
|
+
<div className="flex gap-0.5">
|
|
238
|
+
{harmonyModes.map(({ mode, label, icon }) => (
|
|
239
|
+
<button
|
|
240
|
+
key={mode}
|
|
241
|
+
onClick={() => handleModeChange(mode)}
|
|
242
|
+
className={`flex-1 py-1.5 rounded-lg text-xs transition-all ${
|
|
243
|
+
harmonyMode === mode ? 'font-medium' : 'text-gray-500 hover:text-white'
|
|
244
|
+
}`}
|
|
245
|
+
style={harmonyMode === mode ? {
|
|
246
|
+
backgroundColor: displayPalette.primaryMuted,
|
|
247
|
+
color: displayPalette.textPrimary,
|
|
248
|
+
} : undefined}
|
|
249
|
+
title={label}
|
|
250
|
+
>
|
|
251
|
+
{icon}
|
|
252
|
+
</button>
|
|
253
|
+
))}
|
|
254
|
+
</div>
|
|
255
|
+
<div className="text-center text-[10px] text-gray-500 mt-0.5">
|
|
256
|
+
{harmonyModes.find(m => m.mode === harmonyMode)?.label}
|
|
257
|
+
{harmonyMode === 'custom' && ' — dot = move one · ring = rotate all'}
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
{/* Swatches with hue labels */}
|
|
262
|
+
<div className="px-3 pb-2">
|
|
263
|
+
<div className="flex gap-1">
|
|
264
|
+
{displayHues.slice(0, 5).map((h, i) => (
|
|
265
|
+
<div key={i} className="flex-1 flex flex-col items-center gap-0.5">
|
|
266
|
+
<div className="w-full h-6 rounded-lg" style={{ backgroundColor: `oklch(65% 0.25 ${h})` }} title={swatchLabels[i]} />
|
|
267
|
+
<span className="text-[8px] text-gray-600">{Math.round(h)}°</span>
|
|
268
|
+
</div>
|
|
269
|
+
))}
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
{/* Gradient preview */}
|
|
274
|
+
<div className="px-3 pb-2">
|
|
275
|
+
<div className="h-4 rounded-lg" style={{ background: displayPalette.gradientPrimary }} />
|
|
276
|
+
</div>
|
|
277
|
+
|
|
278
|
+
{/* Actions — always visible */}
|
|
279
|
+
<div className="px-3 pb-3 flex gap-2">
|
|
280
|
+
<button
|
|
281
|
+
onClick={showConfig}
|
|
282
|
+
className="flex-1 py-1.5 rounded-xl text-xs transition-all hover:opacity-80"
|
|
283
|
+
style={{ backgroundColor: displayPalette.primaryMuted, color: displayPalette.textPrimary }}
|
|
284
|
+
>
|
|
285
|
+
📋 Fixar Tema
|
|
286
|
+
</button>
|
|
287
|
+
{isManual && (
|
|
288
|
+
<button
|
|
289
|
+
onClick={handleAuto}
|
|
290
|
+
className="px-3 py-1.5 rounded-xl text-xs transition-all hover:opacity-80 border"
|
|
291
|
+
style={{ borderColor: displayPalette.border, color: displayPalette.textSecondary }}
|
|
292
|
+
>
|
|
293
|
+
↻
|
|
294
|
+
</button>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
{showConfigModal && (
|
|
299
|
+
<ConfigModal
|
|
300
|
+
config={configRef.current}
|
|
301
|
+
palette={displayPalette}
|
|
302
|
+
onClose={() => setShowConfigModal(false)}
|
|
303
|
+
/>
|
|
304
|
+
)}
|
|
305
|
+
</div>
|
|
306
|
+
)
|
|
307
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme Configuration
|
|
3
|
+
*
|
|
4
|
+
* Control how the app's color palette behaves.
|
|
5
|
+
*
|
|
6
|
+
* === Modes ===
|
|
7
|
+
*
|
|
8
|
+
* 'auto' → colors shift with the time of day
|
|
9
|
+
* 'fixed' → fixed base hue with default harmony offsets
|
|
10
|
+
* 'custom' → full control over every color position
|
|
11
|
+
*
|
|
12
|
+
* === Examples ===
|
|
13
|
+
*
|
|
14
|
+
* // Auto mode (default)
|
|
15
|
+
* export const themeConfig = { mode: 'auto' }
|
|
16
|
+
*
|
|
17
|
+
* // Fixed purple
|
|
18
|
+
* export const themeConfig = { mode: 'fixed', hue: 270 }
|
|
19
|
+
*
|
|
20
|
+
* // Custom palette — you define every color offset
|
|
21
|
+
* export const themeConfig = {
|
|
22
|
+
* mode: 'custom',
|
|
23
|
+
* palette: {
|
|
24
|
+
* primary: 270, // purple
|
|
25
|
+
* secondary: 310, // pink (270 + 40)
|
|
26
|
+
* tertiary: 240, // indigo (270 - 30)
|
|
27
|
+
* complement: 90, // yellow (270 + 180)
|
|
28
|
+
* accent: 30, // orange (270 + 120)
|
|
29
|
+
* }
|
|
30
|
+
* }
|
|
31
|
+
*
|
|
32
|
+
* // Custom with offsets from base — easier to tweak
|
|
33
|
+
* export const themeConfig = {
|
|
34
|
+
* mode: 'custom',
|
|
35
|
+
* hue: 270,
|
|
36
|
+
* offsets: {
|
|
37
|
+
* secondary: +50, // default is +40
|
|
38
|
+
* tertiary: -45, // default is -30
|
|
39
|
+
* complement: +160, // default is +180
|
|
40
|
+
* accent: +100, // default is +120
|
|
41
|
+
* }
|
|
42
|
+
* }
|
|
43
|
+
*
|
|
44
|
+
* === Hue Reference ===
|
|
45
|
+
* 0° = Red 180° = Cyan
|
|
46
|
+
* 30° = Orange 210° = Blue
|
|
47
|
+
* 60° = Yellow 270° = Purple
|
|
48
|
+
* 120° = Green 330° = Magenta
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
export type ThemeMode = 'auto' | 'fixed' | 'custom'
|
|
52
|
+
|
|
53
|
+
export interface HarmonyOffsets {
|
|
54
|
+
/** Offset from base hue for secondary color (default: +40) */
|
|
55
|
+
secondary?: number
|
|
56
|
+
/** Offset from base hue for tertiary color (default: -30) */
|
|
57
|
+
tertiary?: number
|
|
58
|
+
/** Offset from base hue for complement color (default: +180) */
|
|
59
|
+
complement?: number
|
|
60
|
+
/** Offset from base hue for accent color (default: +120) */
|
|
61
|
+
accent?: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface CustomPalette {
|
|
65
|
+
/** Absolute hue for primary (0-360) */
|
|
66
|
+
primary: number
|
|
67
|
+
/** Absolute hue for secondary (0-360) */
|
|
68
|
+
secondary: number
|
|
69
|
+
/** Absolute hue for tertiary (0-360) */
|
|
70
|
+
tertiary: number
|
|
71
|
+
/** Absolute hue for complement (0-360) */
|
|
72
|
+
complement: number
|
|
73
|
+
/** Absolute hue for accent (0-360) */
|
|
74
|
+
accent: number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ThemeConfig {
|
|
78
|
+
/** 'auto' = shifts with time, 'fixed' = one hue, 'custom' = full control */
|
|
79
|
+
mode: ThemeMode
|
|
80
|
+
/** Base hue (0-360) — used in 'fixed' and 'custom' with offsets */
|
|
81
|
+
hue?: number
|
|
82
|
+
/** Custom harmony offsets from base hue — used in 'custom' mode */
|
|
83
|
+
offsets?: HarmonyOffsets
|
|
84
|
+
/** Absolute hue positions — used in 'custom' mode (overrides offsets) */
|
|
85
|
+
palette?: CustomPalette
|
|
86
|
+
/** Show the floating theme picker (default: true in dev) */
|
|
87
|
+
showPicker?: boolean
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ===== Default harmony offsets =====
|
|
91
|
+
export const DEFAULT_OFFSETS: Required<HarmonyOffsets> = {
|
|
92
|
+
secondary: 40,
|
|
93
|
+
tertiary: -30,
|
|
94
|
+
complement: 180,
|
|
95
|
+
accent: 120,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const themeConfig: ThemeConfig = {
|
|
99
|
+
mode: 'fixed',
|
|
100
|
+
hue: 270, // purple
|
|
101
|
+
|
|
102
|
+
// ── Auto mode — colors shift with the time of day ─────
|
|
103
|
+
// mode: 'auto',
|
|
104
|
+
|
|
105
|
+
// ── Custom mode with offsets from base hue ────────────
|
|
106
|
+
// mode: 'custom',
|
|
107
|
+
// hue: 270,
|
|
108
|
+
// offsets: {
|
|
109
|
+
// secondary: +40,
|
|
110
|
+
// tertiary: -30,
|
|
111
|
+
// complement: +180,
|
|
112
|
+
// accent: +120,
|
|
113
|
+
// },
|
|
114
|
+
|
|
115
|
+
// ── Custom mode with absolute hue positions ───────────
|
|
116
|
+
// mode: 'custom',
|
|
117
|
+
// palette: {
|
|
118
|
+
// primary: 270, // purple
|
|
119
|
+
// secondary: 310, // pink
|
|
120
|
+
// tertiary: 240, // indigo
|
|
121
|
+
// complement: 90, // yellow
|
|
122
|
+
// accent: 30, // orange
|
|
123
|
+
// },
|
|
124
|
+
|
|
125
|
+
// Show color wheel picker (🎨) — visible in dev only
|
|
126
|
+
showPicker: import.meta.env.DEV,
|
|
127
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React hook for the palette system — respects theme.config.ts
|
|
3
|
+
*
|
|
4
|
+
* Modes:
|
|
5
|
+
* - 'auto' → palette shifts with time (updates every 30s)
|
|
6
|
+
* - 'fixed' → palette stays on configured hue with default offsets
|
|
7
|
+
* - 'custom' → palette uses custom offsets or absolute hue positions
|
|
8
|
+
*/
|
|
9
|
+
import { useState, useEffect } from 'react'
|
|
10
|
+
import { generatePalette, buildPaletteFromHues, applyPalette, type ColorPalette } from '../lib/theme-clock'
|
|
11
|
+
import { themeConfig, DEFAULT_OFFSETS } from '../config/theme.config'
|
|
12
|
+
|
|
13
|
+
function getConfiguredPalette(): ColorPalette {
|
|
14
|
+
const { mode, hue, offsets, palette } = themeConfig
|
|
15
|
+
|
|
16
|
+
if (mode === 'custom') {
|
|
17
|
+
// Absolute palette — each hue is defined explicitly
|
|
18
|
+
if (palette) {
|
|
19
|
+
return buildPaletteFromHues(palette.primary, 'midday', {
|
|
20
|
+
secondary: palette.secondary,
|
|
21
|
+
tertiary: palette.tertiary,
|
|
22
|
+
complement: palette.complement,
|
|
23
|
+
accent: palette.accent,
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Offsets from base hue
|
|
28
|
+
if (hue !== undefined) {
|
|
29
|
+
const o = { ...DEFAULT_OFFSETS, ...offsets }
|
|
30
|
+
return buildPaletteFromHues(hue, 'midday', {
|
|
31
|
+
secondary: hue + o.secondary,
|
|
32
|
+
tertiary: hue + o.tertiary,
|
|
33
|
+
complement: hue + o.complement,
|
|
34
|
+
accent: hue + o.accent,
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (mode === 'fixed' && hue !== undefined) {
|
|
40
|
+
return buildPaletteFromHues(hue, 'midday')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Auto mode
|
|
44
|
+
return generatePalette()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function useThemeClock(): ColorPalette {
|
|
48
|
+
const [palette, setPalette] = useState<ColorPalette>(getConfiguredPalette)
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const initial = getConfiguredPalette()
|
|
52
|
+
setPalette(initial)
|
|
53
|
+
applyPalette(initial)
|
|
54
|
+
|
|
55
|
+
if (themeConfig.mode === 'auto') {
|
|
56
|
+
const interval = setInterval(() => {
|
|
57
|
+
const p = generatePalette()
|
|
58
|
+
setPalette(p)
|
|
59
|
+
applyPalette(p)
|
|
60
|
+
}, 30_000)
|
|
61
|
+
return () => clearInterval(interval)
|
|
62
|
+
}
|
|
63
|
+
}, [])
|
|
64
|
+
|
|
65
|
+
return palette
|
|
66
|
+
}
|
package/app/client/src/index.css
CHANGED
|
@@ -1,5 +1,198 @@
|
|
|
1
1
|
@import "tailwindcss";
|
|
2
2
|
|
|
3
|
+
/* ===== Theme Clock CSS Variables ===== */
|
|
4
|
+
/* Default values (overridden at runtime by theme-clock.ts) */
|
|
5
|
+
:root {
|
|
6
|
+
--theme-hue: 270;
|
|
7
|
+
--theme-primary: oklch(65% 0.25 270);
|
|
8
|
+
--theme-secondary: oklch(70% 0.20 310);
|
|
9
|
+
--theme-tertiary: oklch(60% 0.22 240);
|
|
10
|
+
--theme-complement: oklch(68% 0.18 90);
|
|
11
|
+
--theme-accent: oklch(72% 0.20 30);
|
|
12
|
+
--theme-primary-muted: oklch(65% 0.25 270 / 0.15);
|
|
13
|
+
--theme-secondary-muted: oklch(70% 0.20 310 / 0.10);
|
|
14
|
+
--theme-primary-glow: oklch(65% 0.25 270 / 0.3);
|
|
15
|
+
--theme-secondary-glow: oklch(70% 0.20 310 / 0.2);
|
|
16
|
+
--theme-bg-accent: oklch(65% 0.25 270 / 0.08);
|
|
17
|
+
--theme-bg-accent2: oklch(70% 0.20 310 / 0.05);
|
|
18
|
+
--theme-text-primary: oklch(80% 0.15 270);
|
|
19
|
+
--theme-text-secondary: oklch(75% 0.12 310);
|
|
20
|
+
--theme-text-muted: oklch(55% 0.05 270);
|
|
21
|
+
--theme-border: oklch(65% 0.25 270 / 0.12);
|
|
22
|
+
--theme-border-active: oklch(65% 0.25 270 / 0.3);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* ===== Theme utility classes ===== */
|
|
26
|
+
/* Use these instead of fixed purple-500 etc. for dynamic theming */
|
|
27
|
+
.text-theme { color: var(--theme-primary); }
|
|
28
|
+
.text-theme-secondary { color: var(--theme-secondary); }
|
|
29
|
+
.text-theme-muted { color: var(--theme-text-muted); }
|
|
30
|
+
.bg-theme { background-color: var(--theme-primary); }
|
|
31
|
+
.bg-theme-muted { background-color: var(--theme-primary-muted); }
|
|
32
|
+
.bg-theme-accent { background-color: var(--theme-bg-accent); }
|
|
33
|
+
.border-theme { border-color: var(--theme-border); }
|
|
34
|
+
.border-theme-active { border-color: var(--theme-border-active); }
|
|
35
|
+
.shadow-theme { box-shadow: 0 4px 14px var(--theme-primary-glow); }
|
|
36
|
+
.glow-theme { filter: drop-shadow(0 0 12px var(--theme-primary-glow)); }
|
|
37
|
+
|
|
38
|
+
.bg-theme-gradient {
|
|
39
|
+
background-image: var(--theme-gradient-primary,
|
|
40
|
+
linear-gradient(to right, var(--theme-primary), var(--theme-secondary)));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* Hover variants */
|
|
44
|
+
.hover\:bg-theme-muted:hover { background-color: var(--theme-primary-muted); }
|
|
45
|
+
.hover\:border-theme:hover { border-color: var(--theme-border-active); }
|
|
46
|
+
.hover\:text-theme:hover { color: var(--theme-primary); }
|
|
47
|
+
.hover\:bg-theme:hover { background-color: var(--theme-primary); }
|
|
48
|
+
.hover\:shadow-theme:hover { box-shadow: 0 8px 24px var(--theme-primary-glow); }
|
|
49
|
+
|
|
50
|
+
/* ===== Themed component classes ===== */
|
|
51
|
+
|
|
52
|
+
/* Primary button — filled with theme color */
|
|
53
|
+
.btn-theme {
|
|
54
|
+
background-color: var(--theme-primary);
|
|
55
|
+
color: white;
|
|
56
|
+
border-radius: 0.75rem;
|
|
57
|
+
padding: 0.5rem 1.25rem;
|
|
58
|
+
font-weight: 500;
|
|
59
|
+
font-size: 0.875rem;
|
|
60
|
+
transition: all 0.15s;
|
|
61
|
+
box-shadow: 0 4px 14px var(--theme-primary-glow);
|
|
62
|
+
}
|
|
63
|
+
.btn-theme:hover {
|
|
64
|
+
filter: brightness(1.1);
|
|
65
|
+
box-shadow: 0 8px 24px var(--theme-primary-glow);
|
|
66
|
+
}
|
|
67
|
+
.btn-theme:disabled {
|
|
68
|
+
opacity: 0.5;
|
|
69
|
+
cursor: not-allowed;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* Outline button — border with theme color */
|
|
73
|
+
.btn-theme-outline {
|
|
74
|
+
background-color: var(--theme-primary-muted);
|
|
75
|
+
color: var(--theme-text-primary);
|
|
76
|
+
border: 1px solid var(--theme-border-active);
|
|
77
|
+
border-radius: 0.75rem;
|
|
78
|
+
padding: 0.5rem 1.25rem;
|
|
79
|
+
font-weight: 500;
|
|
80
|
+
font-size: 0.875rem;
|
|
81
|
+
transition: all 0.15s;
|
|
82
|
+
}
|
|
83
|
+
.btn-theme-outline:hover {
|
|
84
|
+
background-color: var(--theme-primary);
|
|
85
|
+
color: white;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* Ghost button — subtle theme tint */
|
|
89
|
+
.btn-theme-ghost {
|
|
90
|
+
background-color: transparent;
|
|
91
|
+
color: var(--theme-text-primary);
|
|
92
|
+
border-radius: 0.75rem;
|
|
93
|
+
padding: 0.5rem 1.25rem;
|
|
94
|
+
font-weight: 500;
|
|
95
|
+
font-size: 0.875rem;
|
|
96
|
+
transition: all 0.15s;
|
|
97
|
+
}
|
|
98
|
+
.btn-theme-ghost:hover {
|
|
99
|
+
background-color: var(--theme-primary-muted);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* Themed input */
|
|
103
|
+
.input-theme {
|
|
104
|
+
background-color: rgba(255, 255, 255, 0.03);
|
|
105
|
+
border: 1px solid var(--theme-border);
|
|
106
|
+
border-radius: 0.75rem;
|
|
107
|
+
padding: 0.625rem 1rem;
|
|
108
|
+
color: white;
|
|
109
|
+
font-size: 0.875rem;
|
|
110
|
+
transition: border-color 0.15s;
|
|
111
|
+
outline: none;
|
|
112
|
+
}
|
|
113
|
+
.input-theme:focus {
|
|
114
|
+
border-color: var(--theme-primary);
|
|
115
|
+
box-shadow: 0 0 0 2px var(--theme-primary-glow);
|
|
116
|
+
}
|
|
117
|
+
.input-theme::placeholder {
|
|
118
|
+
color: rgba(255, 255, 255, 0.3);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* Themed card */
|
|
122
|
+
.card-theme {
|
|
123
|
+
background-color: rgba(255, 255, 255, 0.02);
|
|
124
|
+
border: 1px solid var(--theme-border);
|
|
125
|
+
border-radius: 0.75rem;
|
|
126
|
+
transition: all 0.2s;
|
|
127
|
+
}
|
|
128
|
+
.card-theme:hover {
|
|
129
|
+
background-color: rgba(255, 255, 255, 0.04);
|
|
130
|
+
border-color: var(--theme-border-active);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* Themed badge/tag */
|
|
134
|
+
.badge-theme {
|
|
135
|
+
background-color: var(--theme-primary-muted);
|
|
136
|
+
color: var(--theme-text-primary);
|
|
137
|
+
border-radius: 9999px;
|
|
138
|
+
padding: 0.125rem 0.625rem;
|
|
139
|
+
font-size: 0.75rem;
|
|
140
|
+
font-weight: 500;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* Complement/accent colored buttons */
|
|
144
|
+
.btn-complement {
|
|
145
|
+
background-color: color-mix(in oklch, var(--theme-complement) 20%, transparent);
|
|
146
|
+
color: var(--theme-complement);
|
|
147
|
+
border: 1px solid color-mix(in oklch, var(--theme-complement) 30%, transparent);
|
|
148
|
+
border-radius: 0.75rem;
|
|
149
|
+
transition: all 0.15s;
|
|
150
|
+
}
|
|
151
|
+
.btn-complement:hover {
|
|
152
|
+
background-color: color-mix(in oklch, var(--theme-complement) 30%, transparent);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.btn-accent {
|
|
156
|
+
background-color: color-mix(in oklch, var(--theme-accent) 20%, transparent);
|
|
157
|
+
color: var(--theme-accent);
|
|
158
|
+
border: 1px solid color-mix(in oklch, var(--theme-accent) 30%, transparent);
|
|
159
|
+
border-radius: 0.75rem;
|
|
160
|
+
transition: all 0.15s;
|
|
161
|
+
}
|
|
162
|
+
.btn-accent:hover {
|
|
163
|
+
background-color: color-mix(in oklch, var(--theme-accent) 30%, transparent);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.btn-secondary {
|
|
167
|
+
background-color: color-mix(in oklch, var(--theme-secondary) 20%, transparent);
|
|
168
|
+
color: var(--theme-secondary);
|
|
169
|
+
border: 1px solid color-mix(in oklch, var(--theme-secondary) 30%, transparent);
|
|
170
|
+
border-radius: 0.75rem;
|
|
171
|
+
transition: all 0.15s;
|
|
172
|
+
}
|
|
173
|
+
.btn-secondary:hover {
|
|
174
|
+
background-color: color-mix(in oklch, var(--theme-secondary) 30%, transparent);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.btn-tertiary {
|
|
178
|
+
background-color: color-mix(in oklch, var(--theme-tertiary) 20%, transparent);
|
|
179
|
+
color: var(--theme-tertiary);
|
|
180
|
+
border: 1px solid color-mix(in oklch, var(--theme-tertiary) 30%, transparent);
|
|
181
|
+
border-radius: 0.75rem;
|
|
182
|
+
transition: all 0.15s;
|
|
183
|
+
}
|
|
184
|
+
.btn-tertiary:hover {
|
|
185
|
+
background-color: color-mix(in oklch, var(--theme-tertiary) 30%, transparent);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* Themed scrollbar */
|
|
189
|
+
::-webkit-scrollbar-thumb {
|
|
190
|
+
background: var(--theme-border) !important;
|
|
191
|
+
}
|
|
192
|
+
::-webkit-scrollbar-thumb:hover {
|
|
193
|
+
background: var(--theme-border-active) !important;
|
|
194
|
+
}
|
|
195
|
+
|
|
3
196
|
/* Base styles */
|
|
4
197
|
* {
|
|
5
198
|
box-sizing: border-box;
|