@ramme-io/create-app 2.0.0-alpha.4 → 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.
- package/README.md +2 -2
- package/index.js +3 -3
- package/package.json +1 -1
- package/template/index.html +1 -1
- package/template/package.json +15 -6
- package/template/public/_redirects +1 -0
- package/template/src/App.tsx +3 -2
- package/template/src/components/AppHeader.tsx +5 -0
- package/template/src/components/ScrollToTop.tsx +6 -6
- package/template/src/features/ai/pages/AiChat.tsx +136 -37
- package/template/src/features/auth/AuthContext.tsx +16 -6
- package/template/src/features/config/AppConfigContext.tsx +2 -2
- package/template/src/features/docs/pages/EdgeTelemetryDemo.tsx +149 -0
- package/template/src/features/onboarding/pages/AboutRamme.tsx +12 -12
- package/template/src/features/onboarding/pages/PrototypeGallery.tsx +8 -6
- package/template/src/features/onboarding/pages/RammeFeatures.tsx +18 -17
- package/template/src/features/onboarding/pages/RammeTutorial.tsx +20 -11
- package/template/src/features/onboarding/pages/Welcome.tsx +6 -6
- package/template/src/features/styleguide/sections/tables/TablesSection.tsx +25 -5
- package/template/src/features/theme/pages/ThemeCustomizerPage.tsx +344 -256
- package/template/src/features/theme/utils/ThemeGenerator.logic.ts +587 -0
- package/template/src/hooks/__tests__/useStudioHotkeys.test.ts +100 -0
- package/template/src/hooks/useStudioHotkeys.ts +36 -0
- package/template/src/index.css +91 -1
- package/template/src/main.tsx +44 -2
- package/template/src/templates/dashboard/DashboardLayout.tsx +6 -1
- package/template/src/templates/dashboard/dashboard.sitemap.ts +1 -1
- package/template/src/templates/docs/docs.sitemap.ts +8 -0
- package/template/src/templates/settings/SettingsLayout.tsx +13 -26
- package/template/src/test/setup.ts +1 -0
- package/template/tsconfig.app.json +1 -0
- 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
|
+
});
|