@ramme-io/create-app 2.0.0 → 2.0.1

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.
Files changed (32) hide show
  1. package/README.md +2 -2
  2. package/index.js +1 -1
  3. package/package.json +1 -1
  4. package/template/index.html +1 -1
  5. package/template/package.json +14 -5
  6. package/template/public/_redirects +1 -0
  7. package/template/src/App.tsx +3 -2
  8. package/template/src/components/AppHeader.tsx +5 -0
  9. package/template/src/components/ScrollToTop.tsx +6 -6
  10. package/template/src/features/ai/pages/AiChat.tsx +136 -37
  11. package/template/src/features/auth/AuthContext.tsx +16 -6
  12. package/template/src/features/config/AppConfigContext.tsx +2 -2
  13. package/template/src/features/docs/pages/EdgeTelemetryDemo.tsx +149 -0
  14. package/template/src/features/onboarding/pages/AboutRamme.tsx +12 -12
  15. package/template/src/features/onboarding/pages/PrototypeGallery.tsx +8 -6
  16. package/template/src/features/onboarding/pages/RammeFeatures.tsx +18 -17
  17. package/template/src/features/onboarding/pages/RammeTutorial.tsx +20 -11
  18. package/template/src/features/onboarding/pages/Welcome.tsx +6 -6
  19. package/template/src/features/styleguide/sections/tables/TablesSection.tsx +25 -5
  20. package/template/src/features/theme/pages/ThemeCustomizerPage.tsx +344 -256
  21. package/template/src/features/theme/utils/ThemeGenerator.logic.ts +587 -0
  22. package/template/src/hooks/__tests__/useStudioHotkeys.test.ts +100 -0
  23. package/template/src/hooks/useStudioHotkeys.ts +36 -0
  24. package/template/src/index.css +91 -1
  25. package/template/src/main.tsx +44 -2
  26. package/template/src/templates/dashboard/DashboardLayout.tsx +6 -1
  27. package/template/src/templates/dashboard/dashboard.sitemap.ts +1 -1
  28. package/template/src/templates/docs/docs.sitemap.ts +8 -0
  29. package/template/src/templates/settings/SettingsLayout.tsx +13 -26
  30. package/template/src/test/setup.ts +1 -0
  31. package/template/tsconfig.app.json +1 -0
  32. package/template/vite.config.ts +7 -0
@@ -0,0 +1,587 @@
1
+ /**
2
+ * ThemeGenerator.logic.ts — The Core Engine for Ramme 2.0 Theme Lab
3
+ *
4
+ * HSL-based token generation with:
5
+ * - Aesthetic Profiles (Cyberpunk, Nordic, Earthy, Corporate, Minimalist, Sunset, Ocean)
6
+ * - Smart Light→Dark harmony (preserve Chroma, shift Luminance)
7
+ * - Keyword-to-palette mapper for zero-API "AI" generation
8
+ */
9
+
10
+ // ─── HSL TYPES ───────────────────────────────────────────────────────────────
11
+
12
+ export interface HSL {
13
+ h: number; // 0-360
14
+ s: number; // 0-100
15
+ l: number; // 0-100
16
+ }
17
+
18
+ export interface ThemePalette {
19
+ background: HSL;
20
+ foreground: HSL;
21
+ primary: HSL;
22
+ secondary: HSL;
23
+ accent: HSL;
24
+ muted: HSL;
25
+ border: HSL;
26
+ card: HSL;
27
+ input: HSL;
28
+ }
29
+
30
+ export interface FontPair {
31
+ heading: string;
32
+ body: string;
33
+ }
34
+
35
+ export interface GeneratedTheme {
36
+ name: string;
37
+ description: string;
38
+ profileId: string;
39
+ fonts: FontPair;
40
+ light: ThemePalette;
41
+ dark: ThemePalette;
42
+ }
43
+
44
+ // ─── HSL ↔ RGB CONVERSION ────────────────────────────────────────────────────
45
+
46
+ export function hslToRgb(h: number, s: number, l: number): [number, number, number] {
47
+ s /= 100;
48
+ l /= 100;
49
+ const k = (n: number) => (n + h / 30) % 12;
50
+ const a = s * Math.min(l, 1 - l);
51
+ const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
52
+ return [
53
+ Math.round(f(0) * 255),
54
+ Math.round(f(8) * 255),
55
+ Math.round(f(4) * 255),
56
+ ];
57
+ }
58
+
59
+ export function hslToRgbString(hsl: HSL): string {
60
+ const [r, g, b] = hslToRgb(hsl.h, hsl.s, hsl.l);
61
+ return `${r} ${g} ${b}`;
62
+ }
63
+
64
+ export function hslToCssString(hsl: HSL): string {
65
+ return `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`;
66
+ }
67
+
68
+ // ─── UTILITY ─────────────────────────────────────────────────────────────────
69
+
70
+ function clamp(val: number, min: number, max: number): number {
71
+ return Math.max(min, Math.min(max, val));
72
+ }
73
+
74
+ function rand(min: number, max: number): number {
75
+ return Math.floor(Math.random() * (max - min + 1)) + min;
76
+ }
77
+
78
+ // ─── LIGHT → DARK HARMONY ────────────────────────────────────────────────────
79
+ // Maintains Chroma (Saturation) while shifting Luminance for readability
80
+
81
+ function lightToDark(palette: ThemePalette): ThemePalette {
82
+ return {
83
+ background: { h: palette.background.h, s: clamp(palette.background.s + 5, 0, 30), l: clamp(rand(6, 14), 0, 100) },
84
+ foreground: { h: palette.foreground.h, s: clamp(palette.foreground.s, 0, 15), l: clamp(rand(88, 96), 0, 100) },
85
+ primary: { h: palette.primary.h, s: palette.primary.s, l: clamp(palette.primary.l + rand(5, 15), 30, 75) },
86
+ secondary: { h: palette.secondary.h, s: palette.secondary.s, l: clamp(palette.secondary.l + rand(5, 10), 30, 70) },
87
+ accent: { h: palette.accent.h, s: palette.accent.s, l: clamp(palette.accent.l + rand(5, 15), 35, 75) },
88
+ muted: { h: palette.background.h, s: clamp(palette.background.s + 3, 0, 20), l: clamp(rand(14, 22), 0, 100) },
89
+ border: { h: palette.background.h, s: clamp(palette.background.s, 0, 20), l: clamp(rand(20, 30), 0, 100) },
90
+ card: { h: palette.background.h, s: clamp(palette.background.s + 3, 0, 25), l: clamp(rand(10, 18), 0, 100) },
91
+ input: { h: palette.background.h, s: clamp(palette.background.s + 2, 0, 20), l: clamp(rand(12, 20), 0, 100) },
92
+ };
93
+ }
94
+
95
+ // ─── AESTHETIC PROFILES ──────────────────────────────────────────────────────
96
+
97
+ export interface AestheticProfile {
98
+ id: string;
99
+ name: string;
100
+ description: string;
101
+ fonts: FontPair;
102
+ generate: () => ThemePalette;
103
+ }
104
+
105
+ const profileCyberpunk: AestheticProfile = {
106
+ id: 'cyberpunk',
107
+ name: 'Cyberpunk',
108
+ description: 'Neon-soaked digital dystopia with electric accents.',
109
+ fonts: { heading: 'Orbitron', body: 'Rajdhani' },
110
+ generate: () => {
111
+ const hue = rand(270, 320); // magenta-purple range
112
+ return {
113
+ background: { h: 260, s: 15, l: 96 },
114
+ foreground: { h: 260, s: 30, l: 10 },
115
+ primary: { h: hue, s: rand(85, 100), l: rand(50, 60) },
116
+ secondary: { h: (hue + 120) % 360, s: rand(80, 95), l: rand(50, 60) },
117
+ accent: { h: rand(160, 190), s: rand(90, 100), l: rand(45, 55) },
118
+ muted: { h: 260, s: 10, l: 92 },
119
+ border: { h: 260, s: 12, l: 85 },
120
+ card: { h: 260, s: 12, l: 98 },
121
+ input: { h: 260, s: 10, l: 97 },
122
+ };
123
+ },
124
+ };
125
+
126
+ const profileNordic: AestheticProfile = {
127
+ id: 'nordic',
128
+ name: 'Nordic',
129
+ description: 'Cool, airy Scandinavian minimalism with subtle blues.',
130
+ fonts: { heading: 'Sora', body: 'Inter' },
131
+ generate: () => {
132
+ const hue = rand(200, 220);
133
+ return {
134
+ background: { h: hue, s: 15, l: 97 },
135
+ foreground: { h: hue, s: 20, l: 15 },
136
+ primary: { h: hue, s: rand(45, 65), l: rand(45, 55) },
137
+ secondary: { h: (hue + 30) % 360, s: rand(30, 45), l: rand(50, 60) },
138
+ accent: { h: rand(340, 360), s: rand(50, 65), l: rand(55, 65) },
139
+ muted: { h: hue, s: 10, l: 93 },
140
+ border: { h: hue, s: 12, l: 87 },
141
+ card: { h: hue, s: 8, l: 99 },
142
+ input: { h: hue, s: 8, l: 98 },
143
+ };
144
+ },
145
+ };
146
+
147
+ const profileEarthy: AestheticProfile = {
148
+ id: 'earthy',
149
+ name: 'Earthy',
150
+ description: 'Warm terracotta, forest greens, and natural tones.',
151
+ fonts: { heading: 'Fraunces', body: 'Outfit' },
152
+ generate: () => {
153
+ const hue = rand(20, 40); // warm orange-brown
154
+ return {
155
+ background: { h: hue, s: 20, l: 96 },
156
+ foreground: { h: hue, s: 25, l: 12 },
157
+ primary: { h: rand(130, 160), s: rand(35, 55), l: rand(35, 50) },
158
+ secondary: { h: hue, s: rand(50, 70), l: rand(45, 55) },
159
+ accent: { h: rand(35, 55), s: rand(65, 85), l: rand(50, 60) },
160
+ muted: { h: hue, s: 15, l: 92 },
161
+ border: { h: hue, s: 18, l: 85 },
162
+ card: { h: hue, s: 12, l: 98 },
163
+ input: { h: hue, s: 10, l: 97 },
164
+ };
165
+ },
166
+ };
167
+
168
+ const profileCorporate: AestheticProfile = {
169
+ id: 'corporate',
170
+ name: 'Corporate High-Tech',
171
+ description: 'Sleek, professional blues and crisp neutrals.',
172
+ fonts: { heading: 'Plus Jakarta Sans', body: 'Inter' },
173
+ generate: () => {
174
+ const hue = rand(210, 230);
175
+ return {
176
+ background: { h: 220, s: 10, l: 97 },
177
+ foreground: { h: 220, s: 15, l: 12 },
178
+ primary: { h: hue, s: rand(70, 90), l: rand(45, 55) },
179
+ secondary: { h: (hue + 160) % 360, s: rand(55, 75), l: rand(45, 55) },
180
+ accent: { h: rand(40, 60), s: rand(75, 90), l: rand(48, 58) },
181
+ muted: { h: 220, s: 8, l: 93 },
182
+ border: { h: 220, s: 10, l: 87 },
183
+ card: { h: 220, s: 6, l: 99 },
184
+ input: { h: 220, s: 6, l: 98 },
185
+ };
186
+ },
187
+ };
188
+
189
+ const profileMinimalist: AestheticProfile = {
190
+ id: 'minimalist',
191
+ name: 'Minimalist',
192
+ description: 'Monochrome elegance with a single accent pop.',
193
+ fonts: { heading: 'Manrope', body: 'Manrope' },
194
+ generate: () => {
195
+ const accentHue = rand(0, 360);
196
+ return {
197
+ background: { h: 0, s: 0, l: 98 },
198
+ foreground: { h: 0, s: 0, l: 8 },
199
+ primary: { h: accentHue, s: rand(60, 80), l: rand(48, 58) },
200
+ secondary: { h: 0, s: 0, l: rand(40, 50) },
201
+ accent: { h: (accentHue + 180) % 360, s: rand(55, 75), l: rand(50, 60) },
202
+ muted: { h: 0, s: 0, l: 94 },
203
+ border: { h: 0, s: 0, l: 88 },
204
+ card: { h: 0, s: 0, l: 100 },
205
+ input: { h: 0, s: 0, l: 99 },
206
+ };
207
+ },
208
+ };
209
+
210
+ const profileSunset: AestheticProfile = {
211
+ id: 'sunset',
212
+ name: 'Sunset',
213
+ description: 'Warm gradients from golden amber to dusky rose.',
214
+ fonts: { heading: 'Playfair Display', body: 'Lora' },
215
+ generate: () => {
216
+ const hue = rand(15, 35);
217
+ return {
218
+ background: { h: hue, s: 25, l: 97 },
219
+ foreground: { h: hue, s: 20, l: 10 },
220
+ primary: { h: rand(340, 360), s: rand(65, 85), l: rand(50, 60) },
221
+ secondary: { h: rand(25, 45), s: rand(75, 90), l: rand(50, 60) },
222
+ accent: { h: rand(275, 310), s: rand(55, 75), l: rand(55, 65) },
223
+ muted: { h: hue, s: 18, l: 93 },
224
+ border: { h: hue, s: 15, l: 87 },
225
+ card: { h: hue, s: 15, l: 99 },
226
+ input: { h: hue, s: 12, l: 98 },
227
+ };
228
+ },
229
+ };
230
+
231
+ const profileOcean: AestheticProfile = {
232
+ id: 'ocean',
233
+ name: 'Ocean Depth',
234
+ description: 'Deep teals, coral highlights, and aquatic mystery.',
235
+ fonts: { heading: 'Space Grotesk', body: 'Outfit' },
236
+ generate: () => {
237
+ const hue = rand(175, 200);
238
+ return {
239
+ background: { h: hue, s: 18, l: 96 },
240
+ foreground: { h: hue, s: 25, l: 10 },
241
+ primary: { h: hue, s: rand(60, 80), l: rand(40, 50) },
242
+ secondary: { h: rand(10, 30), s: rand(65, 85), l: rand(55, 65) },
243
+ accent: { h: rand(45, 65), s: rand(70, 90), l: rand(50, 60) },
244
+ muted: { h: hue, s: 12, l: 92 },
245
+ border: { h: hue, s: 14, l: 85 },
246
+ card: { h: hue, s: 10, l: 98 },
247
+ input: { h: hue, s: 8, l: 97 },
248
+ };
249
+ },
250
+ };
251
+
252
+ export const AESTHETIC_PROFILES: AestheticProfile[] = [
253
+ profileCyberpunk,
254
+ profileNordic,
255
+ profileEarthy,
256
+ profileCorporate,
257
+ profileMinimalist,
258
+ profileSunset,
259
+ profileOcean,
260
+ ];
261
+
262
+ // ─── DESCRIPTIVE PALETTE SYNTHESIZER ─────────────────────────────────────────
263
+ // Generates truly custom palettes from arbitrary text descriptions by analyzing
264
+ // mood, temperature, intensity, color words, and theme of the prompt.
265
+
266
+ const COLOR_HINTS: Record<string, { h: number; s: number }> = {
267
+ red: { h: 0, s: 80 }, crimson: { h: 348, s: 85 }, scarlet: { h: 10, s: 85 },
268
+ orange: { h: 30, s: 85 }, amber: { h: 45, s: 90 }, gold: { h: 50, s: 85 },
269
+ yellow: { h: 55, s: 80 }, lime: { h: 90, s: 75 },
270
+ green: { h: 140, s: 60 }, emerald: { h: 155, s: 70 }, sage: { h: 140, s: 30 },
271
+ olive: { h: 80, s: 40 }, mint: { h: 160, s: 55 }, forest: { h: 145, s: 50 },
272
+ teal: { h: 175, s: 65 }, cyan: { h: 185, s: 75 }, turquoise: { h: 170, s: 60 },
273
+ blue: { h: 220, s: 75 }, navy: { h: 230, s: 60 }, cobalt: { h: 215, s: 80 },
274
+ sky: { h: 200, s: 70 }, azure: { h: 210, s: 75 }, steel: { h: 210, s: 25 },
275
+ indigo: { h: 260, s: 70 }, violet: { h: 270, s: 70 },
276
+ purple: { h: 280, s: 70 }, magenta: { h: 300, s: 80 }, fuchsia: { h: 310, s: 85 },
277
+ pink: { h: 335, s: 70 }, rose: { h: 345, s: 75 }, blush: { h: 350, s: 50 },
278
+ lavender: { h: 270, s: 50 }, mauve: { h: 290, s: 30 }, lilac: { h: 280, s: 45 },
279
+ coral: { h: 16, s: 80 }, peach: { h: 25, s: 60 }, salmon: { h: 10, s: 65 },
280
+ brown: { h: 30, s: 50 }, chocolate: { h: 25, s: 60 }, coffee: { h: 30, s: 40 },
281
+ tan: { h: 35, s: 40 }, sand: { h: 40, s: 45 }, cream: { h: 45, s: 35 },
282
+ rust: { h: 15, s: 70 }, terracotta: { h: 18, s: 60 }, copper: { h: 22, s: 65 },
283
+ slate: { h: 215, s: 20 }, charcoal: { h: 0, s: 5 }, graphite: { h: 0, s: 8 },
284
+ ivory: { h: 50, s: 20 }, bone: { h: 40, s: 15 }, pearl: { h: 0, s: 10 },
285
+ wine: { h: 340, s: 60 }, burgundy: { h: 345, s: 55 }, maroon: { h: 350, s: 50 },
286
+ plum: { h: 300, s: 40 }, berry: { h: 320, s: 65 },
287
+ moss: { h: 100, s: 35 }, fern: { h: 120, s: 40 }, jungle: { h: 130, s: 55 },
288
+ midnight: { h: 235, s: 50 }, dusk: { h: 260, s: 35 }, twilight: { h: 250, s: 40 },
289
+ sunrise: { h: 30, s: 80 }, sunset: { h: 15, s: 75 }, dawn: { h: 35, s: 60 },
290
+ neon: { h: 130, s: 95 }, electric: { h: 195, s: 95 }, fluorescent: { h: 100, s: 90 },
291
+ };
292
+
293
+ // Mood modifiers extracted from prompts
294
+ interface MoodSignals {
295
+ temperature: number; // -1 (cool) to +1 (warm)
296
+ brightness: number; // -1 (dark) to +1 (bright)
297
+ saturation: number; // -1 (muted) to +1 (vibrant)
298
+ formality: number; // -1 (playful) to +1 (formal)
299
+ energy: number; // -1 (calm) to +1 (energetic)
300
+ }
301
+
302
+ const MOOD_WORDS: Record<string, Partial<MoodSignals>> = {
303
+ // Temperature
304
+ warm: { temperature: 0.8 }, hot: { temperature: 1 }, cozy: { temperature: 0.6 },
305
+ cool: { temperature: -0.7 }, cold: { temperature: -1 }, icy: { temperature: -1 },
306
+ // Brightness
307
+ bright: { brightness: 0.8 }, light: { brightness: 0.6 }, airy: { brightness: 0.7 },
308
+ luminous: { brightness: 0.9 }, glowing: { brightness: 0.7 },
309
+ dark: { brightness: -0.8 }, deep: { brightness: -0.5 }, moody: { brightness: -0.7 },
310
+ shadow: { brightness: -0.6 }, noir: { brightness: -0.9 }, night: { brightness: -0.8 },
311
+ // Saturation
312
+ vibrant: { saturation: 0.9 }, bold: { saturation: 0.7 }, vivid: { saturation: 0.9 },
313
+ rich: { saturation: 0.6 }, intense: { saturation: 0.8 }, loud: { saturation: 0.9 },
314
+ muted: { saturation: -0.7 }, soft: { saturation: -0.5 }, pastel: { saturation: -0.6 },
315
+ subtle: { saturation: -0.5 }, faded: { saturation: -0.7 }, dusty: { saturation: -0.6 },
316
+ gentle: { saturation: -0.4 }, whisper: { saturation: -0.8 },
317
+ // Formality
318
+ elegant: { formality: 0.7 }, luxe: { formality: 0.8 }, luxury: { formality: 0.8 },
319
+ premium: { formality: 0.6 }, sophisticated: { formality: 0.8 }, refined: { formality: 0.7 },
320
+ professional: { formality: 0.9 }, corporate: { formality: 1 }, business: { formality: 0.8 },
321
+ playful: { formality: -0.7 }, fun: { formality: -0.6 }, whimsical: { formality: -0.8 },
322
+ casual: { formality: -0.4 }, friendly: { formality: -0.3 }, quirky: { formality: -0.7 },
323
+ // Energy
324
+ energetic: { energy: 0.8 }, dynamic: { energy: 0.7 }, electric: { energy: 0.9 },
325
+ punchy: { energy: 0.8 }, exciting: { energy: 0.7 }, fiery: { energy: 0.9 },
326
+ calm: { energy: -0.7 }, serene: { energy: -0.8 }, zen: { energy: -0.9 },
327
+ peaceful: { energy: -0.7 }, tranquil: { energy: -0.8 }, relaxed: { energy: -0.5 },
328
+ quiet: { energy: -0.6 }, still: { energy: -0.7 }, minimal: { energy: -0.4 },
329
+ };
330
+
331
+ // Font pairings by mood
332
+ const FONT_SETS: { condition: (m: MoodSignals) => boolean; fonts: FontPair }[] = [
333
+ { condition: m => m.energy > 0.5 && m.saturation > 0.3, fonts: { heading: 'Orbitron', body: 'Rajdhani' } },
334
+ { condition: m => m.formality > 0.5, fonts: { heading: 'Plus Jakarta Sans', body: 'Inter' } },
335
+ { condition: m => m.formality > 0.3 && m.temperature > 0, fonts: { heading: 'Playfair Display', body: 'Source Sans 3' } },
336
+ { condition: m => m.energy < -0.3 && m.saturation < 0, fonts: { heading: 'Sora', body: 'Inter' } },
337
+ { condition: m => m.temperature > 0.3, fonts: { heading: 'Fraunces', body: 'Outfit' } },
338
+ { condition: m => m.temperature < -0.3, fonts: { heading: 'Space Grotesk', body: 'Outfit' } },
339
+ { condition: m => m.saturation < -0.3, fonts: { heading: 'Manrope', body: 'Manrope' } },
340
+ { condition: m => m.energy > 0.3, fonts: { heading: 'Archivo Black', body: 'DM Sans' } },
341
+ { condition: m => m.brightness > 0.3, fonts: { heading: 'Sora', body: 'Nunito' } },
342
+ { condition: m => m.brightness < -0.3, fonts: { heading: 'Space Grotesk', body: 'JetBrains Mono' } },
343
+ ];
344
+
345
+ const DEFAULT_FONTS: FontPair = { heading: 'DM Sans', body: 'Inter' };
346
+
347
+ function analyzeMood(prompt: string): MoodSignals {
348
+ const lower = prompt.toLowerCase();
349
+ const signals: MoodSignals = { temperature: 0, brightness: 0, saturation: 0, formality: 0, energy: 0 };
350
+ let matches = 0;
351
+
352
+ for (const [word, mods] of Object.entries(MOOD_WORDS)) {
353
+ if (lower.includes(word)) {
354
+ for (const [key, val] of Object.entries(mods)) {
355
+ (signals as any)[key] += val;
356
+ }
357
+ matches++;
358
+ }
359
+ }
360
+
361
+ // Normalize to -1..1 range if many matches
362
+ if (matches > 1) {
363
+ for (const key of Object.keys(signals) as (keyof MoodSignals)[]) {
364
+ signals[key] = clamp(signals[key] / Math.sqrt(matches), -1, 1);
365
+ }
366
+ }
367
+
368
+ return signals;
369
+ }
370
+
371
+ function extractColors(prompt: string): { h: number; s: number }[] {
372
+ const lower = prompt.toLowerCase();
373
+ const found: { h: number; s: number }[] = [];
374
+ const seen = new Set<number>();
375
+
376
+ for (const [word, hint] of Object.entries(COLOR_HINTS)) {
377
+ if (lower.includes(word) && !seen.has(hint.h)) {
378
+ found.push(hint);
379
+ seen.add(hint.h);
380
+ }
381
+ }
382
+ return found;
383
+ }
384
+
385
+ function pickFonts(mood: MoodSignals): FontPair {
386
+ for (const set of FONT_SETS) {
387
+ if (set.condition(mood)) return set.fonts;
388
+ }
389
+ return DEFAULT_FONTS;
390
+ }
391
+
392
+ function synthesizePalette(colors: { h: number; s: number }[], mood: MoodSignals): ThemePalette {
393
+ // Determine base hue — use first color hint, or derive from temperature
394
+ let primaryHue: number;
395
+ let primarySat: number;
396
+
397
+ if (colors.length > 0) {
398
+ primaryHue = colors[0].h;
399
+ primarySat = colors[0].s;
400
+ } else {
401
+ // Derive hue from temperature: warm = reds/oranges, cool = blues/teals
402
+ primaryHue = mood.temperature > 0
403
+ ? rand(0, 40) + Math.round(mood.temperature * 20)
404
+ : rand(180, 260) + Math.round(mood.temperature * 20);
405
+ primarySat = 65 + Math.round(mood.saturation * 20);
406
+ }
407
+
408
+ // Second color for secondary — use second hint or calculate harmony
409
+ let secondaryHue: number;
410
+ let secondarySat: number;
411
+ if (colors.length > 1) {
412
+ secondaryHue = colors[1].h;
413
+ secondarySat = colors[1].s;
414
+ } else {
415
+ // Analogous harmony for formal, triadic for playful
416
+ const offset = mood.formality > 0 ? rand(25, 45) : rand(100, 140);
417
+ secondaryHue = (primaryHue + offset) % 360;
418
+ secondarySat = clamp(primarySat - 10 + rand(-5, 5), 30, 90);
419
+ }
420
+
421
+ // Accent — use third hint or complementary
422
+ let accentHue: number;
423
+ let accentSat: number;
424
+ if (colors.length > 2) {
425
+ accentHue = colors[2].h;
426
+ accentSat = colors[2].s;
427
+ } else {
428
+ accentHue = (primaryHue + 150 + rand(-20, 20)) % 360;
429
+ accentSat = clamp(primarySat + 10 + rand(-5, 5), 40, 95);
430
+ }
431
+
432
+ // Apply mood modifiers to saturation
433
+ const satMod = Math.round(mood.saturation * 15);
434
+ primarySat = clamp(primarySat + satMod, 25, 100);
435
+ secondarySat = clamp(secondarySat + satMod, 20, 95);
436
+ accentSat = clamp(accentSat + satMod, 25, 100);
437
+
438
+ // Lightness — brightness affects primary lightness
439
+ const basePrimaryL = 52 + Math.round(mood.brightness * 8);
440
+ const baseSecondaryL = 50 + Math.round(mood.brightness * 6);
441
+ const baseAccentL = 54 + Math.round(mood.brightness * 6);
442
+
443
+ // Background hue tint — slightly tinted toward primary
444
+ const bgHue = primaryHue;
445
+ const bgSat = clamp(5 + Math.round(Math.abs(mood.saturation) * 10), 0, 25);
446
+ const bgL = mood.brightness > 0 ? rand(97, 99) : rand(94, 97);
447
+
448
+ return {
449
+ background: { h: bgHue, s: bgSat, l: bgL },
450
+ foreground: { h: bgHue, s: clamp(bgSat + 10, 0, 25), l: rand(8, 15) },
451
+ primary: { h: primaryHue, s: primarySat, l: clamp(basePrimaryL + rand(-3, 3), 35, 65) },
452
+ secondary: { h: secondaryHue, s: secondarySat, l: clamp(baseSecondaryL + rand(-3, 3), 35, 65) },
453
+ accent: { h: accentHue, s: accentSat, l: clamp(baseAccentL + rand(-3, 3), 40, 68) },
454
+ muted: { h: bgHue, s: clamp(bgSat + 3, 0, 15), l: rand(91, 94) },
455
+ border: { h: bgHue, s: clamp(bgSat + 2, 0, 18), l: rand(84, 88) },
456
+ card: { h: bgHue, s: clamp(bgSat - 2, 0, 12), l: rand(98, 100) },
457
+ input: { h: bgHue, s: clamp(bgSat - 3, 0, 10), l: rand(97, 99) },
458
+ };
459
+ }
460
+
461
+ // ─── PUBLIC API ──────────────────────────────────────────────────────────────
462
+
463
+ /**
464
+ * Derive a deterministic seed hue (0-360) from any string so that
465
+ * even unrecognized prompts produce a unique, repeatable palette.
466
+ */
467
+ function hashToHue(str: string): number {
468
+ let hash = 0;
469
+ for (let i = 0; i < str.length; i++) {
470
+ hash = str.charCodeAt(i) + ((hash << 5) - hash);
471
+ }
472
+ return ((hash % 360) + 360) % 360;
473
+ }
474
+
475
+ /**
476
+ * Generate a full custom theme from any text description.
477
+ * Always builds from scratch — never falls back to a preset profile.
478
+ * Analyzes mood keywords and color words to shape the palette, and
479
+ * uses a hash of the prompt to seed hue values for unrecognized text.
480
+ * No API key required — runs entirely client-side.
481
+ */
482
+ export function generateFromPrompt(prompt: string): GeneratedTheme {
483
+ const mood = analyzeMood(prompt);
484
+ const colors = extractColors(prompt);
485
+
486
+ // If no color words were detected, seed from the prompt text itself
487
+ // so every unique description produces a unique base hue
488
+ if (colors.length === 0) {
489
+ const seedHue = hashToHue(prompt);
490
+ colors.push({ h: seedHue, s: 65 + rand(-10, 10) });
491
+ }
492
+
493
+ const light = synthesizePalette(colors, mood);
494
+ const dark = lightToDark(light);
495
+ const fonts = pickFonts(mood);
496
+
497
+ // Build a descriptive name
498
+ const detectedColors = extractColors(prompt); // re-extract without seed
499
+ const colorNames = detectedColors.length > 0
500
+ ? Object.entries(COLOR_HINTS)
501
+ .filter(([, hint]) => detectedColors.some(c => c.h === hint.h))
502
+ .map(([name]) => name.charAt(0).toUpperCase() + name.slice(1))
503
+ .slice(0, 3)
504
+ .join(' + ')
505
+ : null;
506
+
507
+ const moodLabel = mood.temperature > 0.3 ? 'Warm' : mood.temperature < -0.3 ? 'Cool' : '';
508
+ const intensityLabel = mood.saturation > 0.3 ? 'Vibrant' : mood.saturation < -0.3 ? 'Soft' : '';
509
+ const namePrefix = [moodLabel, intensityLabel].filter(Boolean).join(' ');
510
+ const nameSuffix = colorNames || prompt.slice(0, 30);
511
+ const themeName = namePrefix ? `${namePrefix} ${nameSuffix}` : nameSuffix;
512
+
513
+ return {
514
+ name: themeName || 'Custom Theme',
515
+ description: `Custom palette generated from: "${prompt}"`,
516
+ profileId: 'custom',
517
+ fonts,
518
+ light,
519
+ dark,
520
+ };
521
+ }
522
+
523
+ /**
524
+ * Generate from a specific Aesthetic Profile ID.
525
+ */
526
+ export function generateFromProfile(profileId: string): GeneratedTheme {
527
+ const profile = AESTHETIC_PROFILES.find(p => p.id === profileId) || AESTHETIC_PROFILES[0];
528
+ const light = profile.generate();
529
+ const dark = lightToDark(light);
530
+
531
+ return {
532
+ name: profile.name,
533
+ description: profile.description,
534
+ profileId: profile.id,
535
+ fonts: profile.fonts,
536
+ light,
537
+ dark,
538
+ };
539
+ }
540
+
541
+ /**
542
+ * Shuffle: pick a random Aesthetic Profile and generate.
543
+ */
544
+ export function generateRandom(): GeneratedTheme {
545
+ const profile = AESTHETIC_PROFILES[rand(0, AESTHETIC_PROFILES.length - 1)];
546
+ const light = profile.generate();
547
+ const dark = lightToDark(light);
548
+
549
+ return {
550
+ name: profile.name,
551
+ description: profile.description,
552
+ profileId: profile.id,
553
+ fonts: profile.fonts,
554
+ light,
555
+ dark,
556
+ };
557
+ }
558
+
559
+ /**
560
+ * Convert a ThemePalette to the --app-* CSS variable Record that applyThemeToDom expects.
561
+ */
562
+ export function paletteToRgbVars(palette: ThemePalette): Record<string, string> {
563
+ const primaryFg = palette.primary.l > 55 ? '15 23 42' : '255 255 255';
564
+ const secondaryFg = palette.secondary.l > 55 ? '15 23 42' : '255 255 255';
565
+ const accentFg = palette.accent.l > 55 ? '15 23 42' : '255 255 255';
566
+
567
+ const primary = hslToRgbString(palette.primary);
568
+
569
+ return {
570
+ '--app-primary-color': primary,
571
+ '--app-primary-foreground-color': primaryFg,
572
+ '--app-secondary-color': hslToRgbString(palette.secondary),
573
+ '--app-secondary-foreground-color': secondaryFg,
574
+ '--app-accent-color': hslToRgbString(palette.accent),
575
+ '--app-accent-foreground-color': accentFg,
576
+ '--app-bg-color': hslToRgbString(palette.background),
577
+ '--app-card-bg-color': hslToRgbString(palette.card),
578
+ '--app-text-color': hslToRgbString(palette.foreground),
579
+ '--app-border-color': hslToRgbString(palette.border),
580
+ '--app-muted-bg': hslToRgbString(palette.muted),
581
+ '--app-muted-text': hslToRgbString({ ...palette.foreground, l: palette.foreground.l > 50 ? palette.foreground.l - 25 : palette.foreground.l + 25 }),
582
+ '--app-input-bg-color': hslToRgbString(palette.input),
583
+ '--app-input-border-color': hslToRgbString(palette.border),
584
+ '--app-chart-1': primary,
585
+ '--app-chart-grid-color': hslToRgbString(palette.border),
586
+ };
587
+ }
@@ -0,0 +1,100 @@
1
+ import { renderHook, act } from '@testing-library/react';
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+
4
+ // Mock useAppConfig
5
+ const mockSetEnableCRT = vi.fn();
6
+ let mockEnableCRT = false;
7
+
8
+ vi.mock('../../features/config/AppConfigContext', () => ({
9
+ useAppConfig: () => ({
10
+ enableCRT: mockEnableCRT,
11
+ setEnableCRT: mockSetEnableCRT,
12
+ }),
13
+ }));
14
+
15
+ // Import after mock setup
16
+ import { useStudioHotkeys } from '../useStudioHotkeys';
17
+
18
+ describe('useStudioHotkeys', () => {
19
+ beforeEach(() => {
20
+ vi.clearAllMocks();
21
+ mockEnableCRT = false;
22
+ document.body.classList.remove('studio-flash');
23
+ });
24
+
25
+ it('toggles CRT when Ctrl+Shift+C is pressed', () => {
26
+ renderHook(() => useStudioHotkeys());
27
+
28
+ act(() => {
29
+ window.dispatchEvent(new KeyboardEvent('keydown', {
30
+ key: 'C', ctrlKey: true, shiftKey: true, bubbles: true,
31
+ }));
32
+ });
33
+
34
+ expect(mockSetEnableCRT).toHaveBeenCalledWith(true);
35
+ });
36
+
37
+ it('does not toggle when only Ctrl+C is pressed (missing Shift)', () => {
38
+ renderHook(() => useStudioHotkeys());
39
+
40
+ act(() => {
41
+ window.dispatchEvent(new KeyboardEvent('keydown', {
42
+ key: 'C', ctrlKey: true, shiftKey: false, bubbles: true,
43
+ }));
44
+ });
45
+
46
+ expect(mockSetEnableCRT).not.toHaveBeenCalled();
47
+ });
48
+
49
+ it('does not toggle when target is an input element', () => {
50
+ renderHook(() => useStudioHotkeys());
51
+ const input = document.createElement('input');
52
+ document.body.appendChild(input);
53
+
54
+ act(() => {
55
+ input.dispatchEvent(new KeyboardEvent('keydown', {
56
+ key: 'C', ctrlKey: true, shiftKey: true, bubbles: true,
57
+ }));
58
+ });
59
+
60
+ expect(mockSetEnableCRT).not.toHaveBeenCalled();
61
+ document.body.removeChild(input);
62
+ });
63
+
64
+ it('does not toggle when target is a textarea', () => {
65
+ renderHook(() => useStudioHotkeys());
66
+ const textarea = document.createElement('textarea');
67
+ document.body.appendChild(textarea);
68
+
69
+ act(() => {
70
+ textarea.dispatchEvent(new KeyboardEvent('keydown', {
71
+ key: 'C', ctrlKey: true, shiftKey: true, bubbles: true,
72
+ }));
73
+ });
74
+
75
+ expect(mockSetEnableCRT).not.toHaveBeenCalled();
76
+ document.body.removeChild(textarea);
77
+ });
78
+
79
+ it('adds studio-flash class to body on toggle', () => {
80
+ renderHook(() => useStudioHotkeys());
81
+
82
+ act(() => {
83
+ window.dispatchEvent(new KeyboardEvent('keydown', {
84
+ key: 'C', ctrlKey: true, shiftKey: true, bubbles: true,
85
+ }));
86
+ });
87
+
88
+ expect(document.body.classList.contains('studio-flash')).toBe(true);
89
+ });
90
+
91
+ it('cleans up event listener on unmount', () => {
92
+ const spy = vi.spyOn(window, 'removeEventListener');
93
+ const { unmount } = renderHook(() => useStudioHotkeys());
94
+
95
+ unmount();
96
+
97
+ expect(spy).toHaveBeenCalledWith('keydown', expect.any(Function));
98
+ spy.mockRestore();
99
+ });
100
+ });