@mindstudio-ai/remy 0.1.34 → 0.1.35
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/dist/headless.js +578 -393
- package/dist/index.js +652 -385
- package/dist/prompt/sources/llms.txt +1618 -0
- package/dist/prompt/static/instructions.md +1 -1
- package/dist/prompt/static/team.md +1 -1
- package/dist/subagents/.notes-background-agents.md +60 -48
- package/dist/subagents/browserAutomation/prompt.md +14 -11
- package/dist/subagents/designExpert/data/sources/dev/index.html +901 -0
- package/dist/subagents/designExpert/data/sources/dev/serve.mjs +244 -0
- package/dist/subagents/designExpert/data/sources/dev/specimens-fonts.html +126 -0
- package/dist/subagents/designExpert/data/sources/dev/specimens-pairings.html +114 -0
- package/dist/subagents/designExpert/data/{fonts.json → sources/fonts.json} +0 -97
- package/dist/subagents/designExpert/data/sources/inspiration.json +392 -0
- package/dist/subagents/designExpert/prompt.md +36 -12
- package/dist/subagents/designExpert/prompts/animation.md +14 -6
- package/dist/subagents/designExpert/prompts/color.md +25 -5
- package/dist/subagents/designExpert/prompts/{icons.md → components.md} +17 -5
- package/dist/subagents/designExpert/prompts/frontend-design-notes.md +17 -122
- package/dist/subagents/designExpert/prompts/identity.md +15 -61
- package/dist/subagents/designExpert/prompts/images.md +35 -10
- package/dist/subagents/designExpert/prompts/layout.md +14 -9
- package/dist/subagents/designExpert/prompts/typography.md +39 -0
- package/package.json +2 -2
- package/dist/actions/buildFromInitialSpec.md +0 -15
- package/dist/actions/publish.md +0 -12
- package/dist/actions/sync.md +0 -19
- package/dist/compiled/README.md +0 -100
- package/dist/compiled/auth.md +0 -77
- package/dist/compiled/design.md +0 -251
- package/dist/compiled/dev-and-deploy.md +0 -69
- package/dist/compiled/interfaces.md +0 -238
- package/dist/compiled/manifest.md +0 -107
- package/dist/compiled/media-cdn.md +0 -51
- package/dist/compiled/methods.md +0 -225
- package/dist/compiled/msfm.md +0 -222
- package/dist/compiled/platform.md +0 -105
- package/dist/compiled/scenarios.md +0 -103
- package/dist/compiled/sdk-actions.md +0 -146
- package/dist/compiled/tables.md +0 -263
- package/dist/static/authoring.md +0 -101
- package/dist/static/coding.md +0 -29
- package/dist/static/identity.md +0 -1
- package/dist/static/instructions.md +0 -31
- package/dist/static/intake.md +0 -44
- package/dist/static/lsp.md +0 -4
- package/dist/static/projectContext.ts +0 -160
- package/dist/static/team.md +0 -39
- package/dist/subagents/designExpert/data/inspiration.json +0 -392
- package/dist/subagents/designExpert/prompts/instructions.md +0 -18
- /package/dist/subagents/designExpert/data/{compile-font-descriptions.sh → sources/compile-font-descriptions.sh} +0 -0
- /package/dist/subagents/designExpert/data/{compile-inspiration.sh → sources/compile-inspiration.sh} +0 -0
- /package/dist/subagents/designExpert/data/{inspiration.raw.json → sources/inspiration.raw.json} +0 -0
- /package/dist/subagents/designExpert/{prompts/tool-prompts → data/sources/prompts}/design-analysis.md +0 -0
- /package/dist/subagents/designExpert/{prompts/tool-prompts → data/sources/prompts}/font-analysis.md +0 -0
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Design Data — Dev Tool</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
9
|
+
|
|
10
|
+
body {
|
|
11
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
12
|
+
background: #fff;
|
|
13
|
+
color: #000;
|
|
14
|
+
line-height: 1.5;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* Header */
|
|
18
|
+
.header {
|
|
19
|
+
position: sticky;
|
|
20
|
+
top: 0;
|
|
21
|
+
z-index: 100;
|
|
22
|
+
background: #fff;
|
|
23
|
+
border-bottom: 1px solid #000;
|
|
24
|
+
height: 48px;
|
|
25
|
+
padding: 0 20px;
|
|
26
|
+
display: flex;
|
|
27
|
+
align-items: center;
|
|
28
|
+
gap: 20px;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.header h1 {
|
|
32
|
+
font-size: 13px;
|
|
33
|
+
font-weight: 600;
|
|
34
|
+
text-transform: uppercase;
|
|
35
|
+
letter-spacing: 0.05em;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.tabs { display: flex; gap: 0; }
|
|
39
|
+
|
|
40
|
+
.tab {
|
|
41
|
+
padding: 6px 14px;
|
|
42
|
+
border: none;
|
|
43
|
+
background: transparent;
|
|
44
|
+
color: #999;
|
|
45
|
+
font-size: 13px;
|
|
46
|
+
font-weight: 500;
|
|
47
|
+
cursor: pointer;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.tab:hover { color: #000; }
|
|
51
|
+
.tab.active { color: #000; text-decoration: underline; text-underline-offset: 3px; }
|
|
52
|
+
|
|
53
|
+
.content { padding: 0; }
|
|
54
|
+
|
|
55
|
+
/* Grid — full bleed, border-based */
|
|
56
|
+
.grid {
|
|
57
|
+
display: grid;
|
|
58
|
+
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.card {
|
|
62
|
+
padding: 20px;
|
|
63
|
+
border-bottom: 1px solid #ddd;
|
|
64
|
+
border-right: 1px solid #ddd;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.card-header {
|
|
68
|
+
display: flex;
|
|
69
|
+
justify-content: space-between;
|
|
70
|
+
align-items: flex-start;
|
|
71
|
+
margin-bottom: 8px;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.card-header h3 { font-size: 14px; font-weight: 600; }
|
|
75
|
+
|
|
76
|
+
/* Buttons */
|
|
77
|
+
.btn {
|
|
78
|
+
padding: 5px 10px;
|
|
79
|
+
border: 1px solid #ddd;
|
|
80
|
+
background: #fff;
|
|
81
|
+
color: #000;
|
|
82
|
+
font-size: 12px;
|
|
83
|
+
cursor: pointer;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.btn:hover { border-color: #000; }
|
|
87
|
+
|
|
88
|
+
.btn-accent {
|
|
89
|
+
background: #000;
|
|
90
|
+
border-color: #000;
|
|
91
|
+
color: #fff;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.btn-accent:hover { background: #333; }
|
|
95
|
+
|
|
96
|
+
.btn-danger {
|
|
97
|
+
background: transparent;
|
|
98
|
+
border: none;
|
|
99
|
+
color: #ccc;
|
|
100
|
+
padding: 4px 8px;
|
|
101
|
+
cursor: pointer;
|
|
102
|
+
font-size: 13px;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.btn-danger:hover { color: #e00; }
|
|
106
|
+
|
|
107
|
+
.btn-sm { padding: 3px 8px; font-size: 11px; }
|
|
108
|
+
|
|
109
|
+
/* Badges */
|
|
110
|
+
.badge {
|
|
111
|
+
display: inline-block;
|
|
112
|
+
padding: 1px 6px;
|
|
113
|
+
font-size: 11px;
|
|
114
|
+
color: #666;
|
|
115
|
+
border: 1px solid #ddd;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.badges { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }
|
|
119
|
+
|
|
120
|
+
/* Font preview */
|
|
121
|
+
.font-preview {
|
|
122
|
+
margin: 10px 0;
|
|
123
|
+
overflow: hidden;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.font-preview-line {
|
|
127
|
+
margin-bottom: 4px;
|
|
128
|
+
font-size: 24px;
|
|
129
|
+
color: #000;
|
|
130
|
+
white-space: nowrap;
|
|
131
|
+
overflow: hidden;
|
|
132
|
+
text-overflow: ellipsis;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.font-preview-small {
|
|
136
|
+
font-size: 14px;
|
|
137
|
+
color: #666;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.font-desc {
|
|
141
|
+
font-size: 12px;
|
|
142
|
+
color: #666;
|
|
143
|
+
line-height: 1.5;
|
|
144
|
+
margin-top: 8px;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.font-meta {
|
|
148
|
+
font-size: 11px;
|
|
149
|
+
color: #999;
|
|
150
|
+
margin-top: 8px;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* Pairing preview */
|
|
154
|
+
.pairing-card {
|
|
155
|
+
padding: 20px;
|
|
156
|
+
border-bottom: 1px solid #ddd;
|
|
157
|
+
border-right: 1px solid #ddd;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.pairing-heading {
|
|
161
|
+
font-size: 28px;
|
|
162
|
+
margin-bottom: 8px;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.pairing-body {
|
|
166
|
+
font-size: 15px;
|
|
167
|
+
color: #444;
|
|
168
|
+
line-height: 1.6;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.pairing-meta {
|
|
172
|
+
margin-top: 12px;
|
|
173
|
+
font-size: 11px;
|
|
174
|
+
color: #999;
|
|
175
|
+
display: flex;
|
|
176
|
+
justify-content: space-between;
|
|
177
|
+
align-items: center;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* Inspiration — filmstrip + detail */
|
|
181
|
+
.inspiration-layout {
|
|
182
|
+
display: flex;
|
|
183
|
+
height: calc(100vh - 48px);
|
|
184
|
+
overflow: hidden;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.inspiration-sidebar {
|
|
188
|
+
width: 160px;
|
|
189
|
+
min-width: 160px;
|
|
190
|
+
border-right: 1px solid #ddd;
|
|
191
|
+
overflow-y: auto;
|
|
192
|
+
scroll-snap-type: y mandatory;
|
|
193
|
+
outline: none;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.inspiration-thumb {
|
|
197
|
+
aspect-ratio: 16/10;
|
|
198
|
+
object-fit: cover;
|
|
199
|
+
display: block;
|
|
200
|
+
width: 100%;
|
|
201
|
+
cursor: pointer;
|
|
202
|
+
border-bottom: 1px solid #ddd;
|
|
203
|
+
opacity: 0.5;
|
|
204
|
+
transition: opacity 0.1s;
|
|
205
|
+
background: #f5f5f5;
|
|
206
|
+
scroll-snap-align: center;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.inspiration-thumb:hover { opacity: 0.8; }
|
|
210
|
+
.inspiration-thumb.active { opacity: 1; }
|
|
211
|
+
|
|
212
|
+
.inspiration-detail {
|
|
213
|
+
flex: 1;
|
|
214
|
+
display: flex;
|
|
215
|
+
min-height: 0;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.inspiration-detail-img {
|
|
219
|
+
width: 50%;
|
|
220
|
+
object-fit: contain;
|
|
221
|
+
object-position: top;
|
|
222
|
+
display: block;
|
|
223
|
+
background: #f5f5f5;
|
|
224
|
+
border-right: 1px solid #ddd;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.inspiration-detail-body {
|
|
228
|
+
width: 50%;
|
|
229
|
+
overflow-y: auto;
|
|
230
|
+
padding: 20px;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.inspiration-detail-header {
|
|
234
|
+
display: flex;
|
|
235
|
+
justify-content: space-between;
|
|
236
|
+
align-items: center;
|
|
237
|
+
margin-bottom: 16px;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.inspiration-detail-header span {
|
|
241
|
+
font-size: 12px;
|
|
242
|
+
color: #999;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.inspiration-analysis {
|
|
246
|
+
font-size: 13px;
|
|
247
|
+
color: #444;
|
|
248
|
+
line-height: 1.7;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.inspiration-analysis h1,
|
|
252
|
+
.inspiration-analysis h2 {
|
|
253
|
+
font-size: 13px;
|
|
254
|
+
font-weight: 600;
|
|
255
|
+
color: #000;
|
|
256
|
+
margin-top: 16px;
|
|
257
|
+
margin-bottom: 4px;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.inspiration-analysis h1:first-child,
|
|
261
|
+
.inspiration-analysis h2:first-child {
|
|
262
|
+
margin-top: 0;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.inspiration-empty {
|
|
266
|
+
display: flex;
|
|
267
|
+
align-items: center;
|
|
268
|
+
justify-content: center;
|
|
269
|
+
flex: 1;
|
|
270
|
+
color: #999;
|
|
271
|
+
font-size: 13px;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/* Forms */
|
|
275
|
+
.form-section {
|
|
276
|
+
border-bottom: 1px solid #ddd;
|
|
277
|
+
padding: 16px 20px;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.form-section h3 {
|
|
281
|
+
font-size: 12px;
|
|
282
|
+
font-weight: 600;
|
|
283
|
+
margin-bottom: 10px;
|
|
284
|
+
text-transform: uppercase;
|
|
285
|
+
letter-spacing: 0.05em;
|
|
286
|
+
color: #999;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.form-row {
|
|
290
|
+
display: flex;
|
|
291
|
+
gap: 8px;
|
|
292
|
+
flex-wrap: wrap;
|
|
293
|
+
margin-bottom: 8px;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.form-field {
|
|
297
|
+
display: flex;
|
|
298
|
+
flex-direction: column;
|
|
299
|
+
gap: 4px;
|
|
300
|
+
flex: 1;
|
|
301
|
+
min-width: 120px;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
.form-field label {
|
|
305
|
+
font-size: 11px;
|
|
306
|
+
color: #999;
|
|
307
|
+
text-transform: uppercase;
|
|
308
|
+
letter-spacing: 0.05em;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
input, select, textarea {
|
|
312
|
+
padding: 6px 8px;
|
|
313
|
+
border: 1px solid #ddd;
|
|
314
|
+
background: #fff;
|
|
315
|
+
color: #000;
|
|
316
|
+
font-size: 13px;
|
|
317
|
+
font-family: inherit;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
input:focus, select:focus, textarea:focus {
|
|
321
|
+
outline: none;
|
|
322
|
+
border-color: #000;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
textarea { resize: vertical; min-height: 60px; }
|
|
326
|
+
|
|
327
|
+
/* Filter bar */
|
|
328
|
+
.filter-bar {
|
|
329
|
+
position: sticky;
|
|
330
|
+
top: 48px;
|
|
331
|
+
z-index: 99;
|
|
332
|
+
background: #fff;
|
|
333
|
+
display: flex;
|
|
334
|
+
gap: 8px;
|
|
335
|
+
padding: 8px 20px;
|
|
336
|
+
border-bottom: 1px solid #ddd;
|
|
337
|
+
align-items: center;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
.filter-bar input,
|
|
341
|
+
.filter-bar select {
|
|
342
|
+
border: none;
|
|
343
|
+
outline: none;
|
|
344
|
+
font-size: 13px;
|
|
345
|
+
padding: 4px 0;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.filter-bar input {
|
|
349
|
+
flex: 1;
|
|
350
|
+
min-width: 200px;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.filter-bar select { color: #999; cursor: pointer; }
|
|
354
|
+
.filter-bar select:focus { color: #000; }
|
|
355
|
+
|
|
356
|
+
.toggle-group { display: flex; gap: 12px; }
|
|
357
|
+
|
|
358
|
+
.toggle {
|
|
359
|
+
padding: 4px 0;
|
|
360
|
+
border: none;
|
|
361
|
+
background: transparent;
|
|
362
|
+
color: #999;
|
|
363
|
+
font-size: 13px;
|
|
364
|
+
cursor: pointer;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
.toggle:hover { color: #000; }
|
|
368
|
+
.toggle.active { color: #000; }
|
|
369
|
+
|
|
370
|
+
/* Loading */
|
|
371
|
+
.loading {
|
|
372
|
+
text-align: center;
|
|
373
|
+
padding: 40px;
|
|
374
|
+
color: #999;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
.spinner {
|
|
378
|
+
display: inline-block;
|
|
379
|
+
width: 16px;
|
|
380
|
+
height: 16px;
|
|
381
|
+
border: 2px solid #ddd;
|
|
382
|
+
border-top-color: #000;
|
|
383
|
+
border-radius: 50%;
|
|
384
|
+
animation: spin 0.6s linear infinite;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
388
|
+
|
|
389
|
+
/* Toast */
|
|
390
|
+
.toast {
|
|
391
|
+
position: fixed;
|
|
392
|
+
bottom: 20px;
|
|
393
|
+
right: 20px;
|
|
394
|
+
background: #fff;
|
|
395
|
+
border: 1px solid #ddd;
|
|
396
|
+
padding: 10px 16px;
|
|
397
|
+
font-size: 13px;
|
|
398
|
+
z-index: 200;
|
|
399
|
+
animation: slideUp 0.2s ease-out;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
.toast.error { border-color: #e00; color: #e00; }
|
|
403
|
+
.toast.success { border-color: #000; }
|
|
404
|
+
|
|
405
|
+
@keyframes slideUp {
|
|
406
|
+
from { transform: translateY(10px); opacity: 0; }
|
|
407
|
+
to { transform: translateY(0); opacity: 1; }
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/* Color swatch */
|
|
411
|
+
.color-swatch {
|
|
412
|
+
display: inline-block;
|
|
413
|
+
width: 12px;
|
|
414
|
+
height: 12px;
|
|
415
|
+
vertical-align: middle;
|
|
416
|
+
margin-right: 2px;
|
|
417
|
+
border: 1px solid #ddd;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/* Duplicate banner */
|
|
421
|
+
.dupe-banner {
|
|
422
|
+
background: #fffbeb;
|
|
423
|
+
color: #92400e;
|
|
424
|
+
padding: 4px 8px;
|
|
425
|
+
font-size: 11px;
|
|
426
|
+
font-weight: 500;
|
|
427
|
+
text-align: center;
|
|
428
|
+
border-bottom: 1px solid #ddd;
|
|
429
|
+
}
|
|
430
|
+
</style>
|
|
431
|
+
</head>
|
|
432
|
+
<body>
|
|
433
|
+
<div id="app"></div>
|
|
434
|
+
|
|
435
|
+
<script type="module">
|
|
436
|
+
import { h, render } from 'https://esm.sh/preact@10.25.4';
|
|
437
|
+
import { useState, useEffect, useCallback, useMemo } from 'https://esm.sh/preact@10.25.4/hooks';
|
|
438
|
+
import htm from 'https://esm.sh/htm@3.1.1';
|
|
439
|
+
|
|
440
|
+
const html = htm.bind(h);
|
|
441
|
+
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
// API helpers
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
async function api(path, opts = {}) {
|
|
447
|
+
const res = await fetch(path, {
|
|
448
|
+
headers: { 'Content-Type': 'application/json' },
|
|
449
|
+
...opts,
|
|
450
|
+
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
451
|
+
});
|
|
452
|
+
const data = await res.json();
|
|
453
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
454
|
+
return data;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
// Hooks
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
function useFonts() {
|
|
462
|
+
const [data, setData] = useState(null);
|
|
463
|
+
const [loading, setLoading] = useState(true);
|
|
464
|
+
const [error, setError] = useState(null);
|
|
465
|
+
|
|
466
|
+
const reload = useCallback(async () => {
|
|
467
|
+
try {
|
|
468
|
+
setLoading(true);
|
|
469
|
+
const d = await api('/api/fonts');
|
|
470
|
+
setData(d);
|
|
471
|
+
setError(null);
|
|
472
|
+
} catch (e) {
|
|
473
|
+
setError(e.message);
|
|
474
|
+
} finally {
|
|
475
|
+
setLoading(false);
|
|
476
|
+
}
|
|
477
|
+
}, []);
|
|
478
|
+
|
|
479
|
+
useEffect(() => { reload(); }, [reload]);
|
|
480
|
+
|
|
481
|
+
const deleteFont = useCallback(async (slug) => {
|
|
482
|
+
await api('/api/fonts/' + encodeURIComponent(slug), { method: 'DELETE' });
|
|
483
|
+
await reload();
|
|
484
|
+
}, [reload]);
|
|
485
|
+
|
|
486
|
+
const deletePairing = useCallback(async (index) => {
|
|
487
|
+
await api('/api/pairings/' + index, { method: 'DELETE' });
|
|
488
|
+
await reload();
|
|
489
|
+
}, [reload]);
|
|
490
|
+
|
|
491
|
+
return { data, loading, error, deleteFont, deletePairing, reload };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function useInspiration() {
|
|
495
|
+
const [data, setData] = useState(null);
|
|
496
|
+
const [loading, setLoading] = useState(true);
|
|
497
|
+
const [error, setError] = useState(null);
|
|
498
|
+
|
|
499
|
+
const reload = useCallback(async () => {
|
|
500
|
+
try {
|
|
501
|
+
setLoading(true);
|
|
502
|
+
const d = await api('/api/inspiration');
|
|
503
|
+
setData(d);
|
|
504
|
+
setError(null);
|
|
505
|
+
} catch (e) {
|
|
506
|
+
setError(e.message);
|
|
507
|
+
} finally {
|
|
508
|
+
setLoading(false);
|
|
509
|
+
}
|
|
510
|
+
}, []);
|
|
511
|
+
|
|
512
|
+
useEffect(() => { reload(); }, [reload]);
|
|
513
|
+
|
|
514
|
+
const addImage = useCallback(async (entry) => {
|
|
515
|
+
await api('/api/inspiration', { method: 'POST', body: entry });
|
|
516
|
+
await reload();
|
|
517
|
+
}, [reload]);
|
|
518
|
+
|
|
519
|
+
const deleteImage = useCallback(async (index) => {
|
|
520
|
+
await api('/api/inspiration/' + index, { method: 'DELETE' });
|
|
521
|
+
await reload();
|
|
522
|
+
}, [reload]);
|
|
523
|
+
|
|
524
|
+
const analyze = useCallback(async (url) => {
|
|
525
|
+
return api('/api/inspiration/analyze', { method: 'POST', body: { url } });
|
|
526
|
+
}, []);
|
|
527
|
+
|
|
528
|
+
const dedup = useCallback(async () => {
|
|
529
|
+
const result = await api('/api/inspiration/dedup', { method: 'POST' });
|
|
530
|
+
await reload();
|
|
531
|
+
return result;
|
|
532
|
+
}, [reload]);
|
|
533
|
+
|
|
534
|
+
return { data, loading, error, addImage, deleteImage, analyze, dedup, reload };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
// Toast
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
function useToast() {
|
|
542
|
+
const [toast, setToast] = useState(null);
|
|
543
|
+
|
|
544
|
+
const show = useCallback((message, type = 'success') => {
|
|
545
|
+
setToast({ message, type });
|
|
546
|
+
setTimeout(() => setToast(null), 3000);
|
|
547
|
+
}, []);
|
|
548
|
+
|
|
549
|
+
const Toast = () => toast
|
|
550
|
+
? html`<div class="toast ${toast.type}">${toast.message}</div>`
|
|
551
|
+
: null;
|
|
552
|
+
|
|
553
|
+
return { show, Toast };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ---------------------------------------------------------------------------
|
|
557
|
+
// Font CSS loading
|
|
558
|
+
// ---------------------------------------------------------------------------
|
|
559
|
+
|
|
560
|
+
const loadedFonts = new Set();
|
|
561
|
+
const CSS_URL_PATTERN = 'https://api.fontshare.com/v2/css?f[]={slug}@{weights}&display=swap';
|
|
562
|
+
|
|
563
|
+
function ensureFontLoaded(font) {
|
|
564
|
+
if (loadedFonts.has(font.slug)) return;
|
|
565
|
+
loadedFonts.add(font.slug);
|
|
566
|
+
|
|
567
|
+
let cssUrl;
|
|
568
|
+
if (font.source === 'fontshare') {
|
|
569
|
+
cssUrl = CSS_URL_PATTERN
|
|
570
|
+
.replace('{slug}', font.slug)
|
|
571
|
+
.replace('{weights}', font.weights.join(','));
|
|
572
|
+
} else if (font.cssUrl) {
|
|
573
|
+
cssUrl = font.cssUrl;
|
|
574
|
+
} else {
|
|
575
|
+
return; // Can't load (e.g. self-host only)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const link = document.createElement('link');
|
|
579
|
+
link.rel = 'stylesheet';
|
|
580
|
+
link.href = cssUrl;
|
|
581
|
+
document.head.appendChild(link);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Also load by slug for pairings
|
|
585
|
+
function ensureFontLoadedBySlug(slug, fonts) {
|
|
586
|
+
const font = fonts?.find(f => f.slug === slug);
|
|
587
|
+
if (font) ensureFontLoaded(font);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// ---------------------------------------------------------------------------
|
|
591
|
+
// Markdown-lite renderer
|
|
592
|
+
// ---------------------------------------------------------------------------
|
|
593
|
+
|
|
594
|
+
function renderAnalysis(text) {
|
|
595
|
+
if (!text) return '';
|
|
596
|
+
// Simple markdown: headers, bold, hex colors
|
|
597
|
+
return text
|
|
598
|
+
.replace(/^### (.+)$/gm, '<h2>$1</h2>')
|
|
599
|
+
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
|
600
|
+
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
|
601
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
602
|
+
.replace(/#([0-9A-Fa-f]{6})\b/g, '<span class="color-swatch" style="background:#$1"></span>#$1')
|
|
603
|
+
.replace(/\n/g, '<br/>');
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ---------------------------------------------------------------------------
|
|
607
|
+
// Components
|
|
608
|
+
// ---------------------------------------------------------------------------
|
|
609
|
+
|
|
610
|
+
function FontCard({ font, onDelete }) {
|
|
611
|
+
ensureFontLoaded(font);
|
|
612
|
+
const family = font.name;
|
|
613
|
+
const sourceClass = font.source === 'fontshare' ? 'source-fontshare'
|
|
614
|
+
: font.source === 'google-fonts' ? 'source-google' : 'source-open-foundry';
|
|
615
|
+
const canPreview = font.source !== 'open-foundry' || font.cssUrl;
|
|
616
|
+
|
|
617
|
+
return html`
|
|
618
|
+
<div class="card">
|
|
619
|
+
<div class="card-header">
|
|
620
|
+
<h3>${font.name}</h3>
|
|
621
|
+
<button class="btn-danger" onClick=${() => onDelete(font.slug)} title="Delete">✕</button>
|
|
622
|
+
</div>
|
|
623
|
+
${canPreview ? html`
|
|
624
|
+
<div class="font-preview">
|
|
625
|
+
<div class="font-preview-line" style="font-family: '${family}'; font-weight: ${font.weights[Math.floor(font.weights.length / 2)] || 400}">
|
|
626
|
+
The quick brown fox jumps
|
|
627
|
+
</div>
|
|
628
|
+
<div class="font-preview-small" style="font-family: '${family}'; font-weight: ${font.weights[0] || 400}">
|
|
629
|
+
Pack my box with five dozen liquor jugs — 0123456789
|
|
630
|
+
</div>
|
|
631
|
+
</div>
|
|
632
|
+
` : html`
|
|
633
|
+
<div class="font-preview" style="color: var(--text2); font-size: 13px; font-style: italic;">
|
|
634
|
+
Self-host required — no preview available
|
|
635
|
+
</div>
|
|
636
|
+
`}
|
|
637
|
+
${font.description ? html`<div class="font-desc">${font.description}</div>` : null}
|
|
638
|
+
<div class="font-meta">
|
|
639
|
+
Weights: ${font.weights.join(', ')}${font.variable ? ' · Variable' : ''}${font.italics ? ' · Italics' : ''}
|
|
640
|
+
</div>
|
|
641
|
+
<div class="badges">
|
|
642
|
+
<span class="badge category">${font.category}</span>
|
|
643
|
+
<span class="badge ${sourceClass}">${font.source}</span>
|
|
644
|
+
</div>
|
|
645
|
+
</div>
|
|
646
|
+
`;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function PairingCard({ pairing, index, fonts, onDelete }) {
|
|
650
|
+
ensureFontLoadedBySlug(pairing.heading.slug, fonts);
|
|
651
|
+
ensureFontLoadedBySlug(pairing.body.slug, fonts);
|
|
652
|
+
|
|
653
|
+
return html`
|
|
654
|
+
<div class="pairing-card">
|
|
655
|
+
<div class="pairing-heading" style="font-family: '${pairing.heading.font}'; font-weight: ${pairing.heading.weight}">
|
|
656
|
+
A thoughtful heading
|
|
657
|
+
</div>
|
|
658
|
+
<div class="pairing-body" style="font-family: '${pairing.body.font}'; font-weight: ${pairing.body.weight}">
|
|
659
|
+
Good design is as little design as possible. Less, but better — because it concentrates on the essential aspects, and the products are not burdened with non-essentials.
|
|
660
|
+
</div>
|
|
661
|
+
<div class="pairing-meta">
|
|
662
|
+
<span>${pairing.heading.font} (${pairing.heading.weight}) + ${pairing.body.font} (${pairing.body.weight})</span>
|
|
663
|
+
<button class="btn-danger" onClick=${() => onDelete(index)} title="Delete">✕</button>
|
|
664
|
+
</div>
|
|
665
|
+
</div>
|
|
666
|
+
`;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function InspirationDetail({ image, index, onDelete }) {
|
|
670
|
+
if (!image) return html`<div class="inspiration-empty">Select an image</div>`;
|
|
671
|
+
|
|
672
|
+
return html`
|
|
673
|
+
<div class="inspiration-detail">
|
|
674
|
+
<img class="inspiration-detail-img" src=${image.url} alt="Design reference" />
|
|
675
|
+
<div class="inspiration-detail-body">
|
|
676
|
+
<div class="inspiration-detail-header">
|
|
677
|
+
<span>#${index}</span>
|
|
678
|
+
<button class="btn-danger" onClick=${() => onDelete(index)} title="Delete">✕</button>
|
|
679
|
+
</div>
|
|
680
|
+
<div class="inspiration-analysis"
|
|
681
|
+
dangerouslySetInnerHTML=${{ __html: renderAnalysis(image.analysis) }}
|
|
682
|
+
/>
|
|
683
|
+
</div>
|
|
684
|
+
</div>
|
|
685
|
+
`;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
function AddInspirationForm({ onAdd, onAnalyze, toast }) {
|
|
690
|
+
const [open, setOpen] = useState(false);
|
|
691
|
+
const [url, setUrl] = useState('');
|
|
692
|
+
const [analysis, setAnalysis] = useState('');
|
|
693
|
+
const [analyzing, setAnalyzing] = useState(false);
|
|
694
|
+
const [saving, setSaving] = useState(false);
|
|
695
|
+
|
|
696
|
+
const runAnalysis = async () => {
|
|
697
|
+
if (!url) return;
|
|
698
|
+
setAnalyzing(true);
|
|
699
|
+
try {
|
|
700
|
+
const result = await onAnalyze(url);
|
|
701
|
+
setAnalysis(result.analysis);
|
|
702
|
+
toast.show('Analysis complete');
|
|
703
|
+
} catch (e) {
|
|
704
|
+
toast.show(e.message, 'error');
|
|
705
|
+
} finally {
|
|
706
|
+
setAnalyzing(false);
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
const submit = async () => {
|
|
711
|
+
if (!url || !analysis) return;
|
|
712
|
+
setSaving(true);
|
|
713
|
+
try {
|
|
714
|
+
await onAdd({ url, analysis });
|
|
715
|
+
toast.show('Image added');
|
|
716
|
+
setUrl('');
|
|
717
|
+
setAnalysis('');
|
|
718
|
+
setOpen(false);
|
|
719
|
+
} catch (e) {
|
|
720
|
+
toast.show(e.message, 'error');
|
|
721
|
+
} finally {
|
|
722
|
+
setSaving(false);
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
if (!open) return html`<button class="btn btn-accent" onClick=${() => setOpen(true)}>+ Add image</button>`;
|
|
727
|
+
|
|
728
|
+
return html`
|
|
729
|
+
<div class="form-section">
|
|
730
|
+
<h3>Add inspiration image</h3>
|
|
731
|
+
<div class="form-row">
|
|
732
|
+
<div class="form-field" style="flex: 3">
|
|
733
|
+
<label>Image URL</label>
|
|
734
|
+
<input value=${url} onInput=${e => setUrl(e.target.value)} placeholder="https://..." />
|
|
735
|
+
</div>
|
|
736
|
+
<div class="form-field" style="flex: 0; align-self: flex-end; min-width: auto;">
|
|
737
|
+
<button class="btn" onClick=${runAnalysis} disabled=${analyzing || !url}>
|
|
738
|
+
${analyzing ? html`<span class="spinner" style="width:14px;height:14px;border-width:2px;vertical-align:middle;margin-right:6px"></span> Analyzing...` : 'Analyze'}
|
|
739
|
+
</button>
|
|
740
|
+
</div>
|
|
741
|
+
</div>
|
|
742
|
+
${url ? html`<img src=${url} style="max-width:300px;border-radius:6px;margin:8px 0" />` : null}
|
|
743
|
+
<div class="form-field" style="margin-top: 8px">
|
|
744
|
+
<label>Analysis</label>
|
|
745
|
+
<textarea rows="8" value=${analysis} onInput=${e => setAnalysis(e.target.value)} placeholder="Paste or auto-generate analysis..." />
|
|
746
|
+
</div>
|
|
747
|
+
<div style="display: flex; gap: 8px; margin-top: 8px;">
|
|
748
|
+
<button class="btn btn-accent" onClick=${submit} disabled=${saving || !url || !analysis}>
|
|
749
|
+
${saving ? 'Saving...' : 'Save'}
|
|
750
|
+
</button>
|
|
751
|
+
<button class="btn" onClick=${() => setOpen(false)}>Cancel</button>
|
|
752
|
+
</div>
|
|
753
|
+
</div>
|
|
754
|
+
`;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// ---------------------------------------------------------------------------
|
|
758
|
+
// Tabs
|
|
759
|
+
// ---------------------------------------------------------------------------
|
|
760
|
+
|
|
761
|
+
function FontsTab({ fonts }) {
|
|
762
|
+
const [search, setSearch] = useState('');
|
|
763
|
+
const [filterCategory, setFilterCategory] = useState('');
|
|
764
|
+
const [filterSource, setFilterSource] = useState('');
|
|
765
|
+
|
|
766
|
+
if (fonts.loading) return html`<div class="loading"><span class="spinner"></span></div>`;
|
|
767
|
+
if (fonts.error) return html`<div class="loading" style="color:#e00">Error: ${fonts.error}</div>`;
|
|
768
|
+
if (!fonts.data) return null;
|
|
769
|
+
|
|
770
|
+
const fontList = fonts.data.fonts;
|
|
771
|
+
const categories = [...new Set(fontList.map(f => f.category))].sort();
|
|
772
|
+
const sources = [...new Set(fontList.map(f => f.source))].sort();
|
|
773
|
+
const sorted = [...fontList].sort((a, b) => a.name.localeCompare(b.name));
|
|
774
|
+
|
|
775
|
+
const filtered = sorted.filter(f => {
|
|
776
|
+
if (search && !f.name.toLowerCase().includes(search.toLowerCase())) return false;
|
|
777
|
+
if (filterCategory && f.category !== filterCategory) return false;
|
|
778
|
+
if (filterSource && f.source !== filterSource) return false;
|
|
779
|
+
return true;
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
return html`
|
|
783
|
+
<div class="filter-bar">
|
|
784
|
+
<input placeholder="Search fonts..." value=${search} onInput=${e => setSearch(e.target.value)} />
|
|
785
|
+
<select value=${filterCategory} onChange=${e => setFilterCategory(e.target.value)}>
|
|
786
|
+
<option value="">All categories</option>
|
|
787
|
+
${categories.map(c => html`<option>${c}</option>`)}
|
|
788
|
+
</select>
|
|
789
|
+
<div class="toggle-group">
|
|
790
|
+
<button class="toggle ${filterSource === '' ? 'active' : ''}" onClick=${() => setFilterSource('')}>All</button>
|
|
791
|
+
${sources.map(s => html`
|
|
792
|
+
<button class="toggle ${filterSource === s ? 'active' : ''}" onClick=${() => setFilterSource(filterSource === s ? '' : s)}>${s}</button>
|
|
793
|
+
`)}
|
|
794
|
+
</div>
|
|
795
|
+
${(search || filterCategory || filterSource) ? html`
|
|
796
|
+
<span style="font-size:13px;color:#999">${filtered.length} shown</span>
|
|
797
|
+
` : null}
|
|
798
|
+
</div>
|
|
799
|
+
<div class="grid">
|
|
800
|
+
${filtered.map(f => html`<${FontCard} key=${f.slug} font=${f} onDelete=${fonts.deleteFont} />`)}
|
|
801
|
+
</div>
|
|
802
|
+
`;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function PairingsTab({ fonts }) {
|
|
806
|
+
if (fonts.loading) return html`<div class="loading"><span class="spinner"></span></div>`;
|
|
807
|
+
if (fonts.error) return html`<div class="loading" style="color:#e00">Error: ${fonts.error}</div>`;
|
|
808
|
+
if (!fonts.data) return null;
|
|
809
|
+
|
|
810
|
+
const { fonts: fontList, pairings } = fonts.data;
|
|
811
|
+
|
|
812
|
+
return html`
|
|
813
|
+
<div class="grid">
|
|
814
|
+
${pairings.map((p, i) => html`<${PairingCard} key=${i} pairing=${p} index=${i} fonts=${fontList} onDelete=${fonts.deletePairing} />`)}
|
|
815
|
+
</div>
|
|
816
|
+
`;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function InspirationTab({ inspiration }) {
|
|
820
|
+
const [selected, setSelected] = useState(0);
|
|
821
|
+
const sidebarRef = { current: null };
|
|
822
|
+
|
|
823
|
+
if (inspiration.loading) return html`<div class="loading"><span class="spinner"></span></div>`;
|
|
824
|
+
if (inspiration.error) return html`<div class="loading" style="color:#e00">Error: ${inspiration.error}</div>`;
|
|
825
|
+
if (!inspiration.data) return null;
|
|
826
|
+
|
|
827
|
+
const images = inspiration.data.images;
|
|
828
|
+
const current = images[selected] || null;
|
|
829
|
+
|
|
830
|
+
const select = (i) => {
|
|
831
|
+
const clamped = Math.max(0, Math.min(i, images.length - 1));
|
|
832
|
+
setSelected(clamped);
|
|
833
|
+
sidebarRef.current?.children[clamped]?.scrollIntoView({ block: 'center' });
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
const onKey = (e) => {
|
|
837
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); select(selected + 1); }
|
|
838
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); select(selected - 1); }
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
const handleDelete = async (index) => {
|
|
842
|
+
const nextIndex = index >= images.length - 1 ? Math.max(0, index - 1) : index;
|
|
843
|
+
setSelected(nextIndex);
|
|
844
|
+
await inspiration.deleteImage(index);
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
return html`
|
|
848
|
+
<div class="inspiration-layout">
|
|
849
|
+
<div class="inspiration-sidebar" ref=${el => sidebarRef.current = el} tabindex="0" onKeyDown=${onKey}>
|
|
850
|
+
${images.map((img, i) => html`
|
|
851
|
+
<img
|
|
852
|
+
key=${i}
|
|
853
|
+
class="inspiration-thumb ${i === selected ? 'active' : ''}"
|
|
854
|
+
src=${img.url}
|
|
855
|
+
loading="lazy"
|
|
856
|
+
onClick=${() => select(i)}
|
|
857
|
+
/>
|
|
858
|
+
`)}
|
|
859
|
+
</div>
|
|
860
|
+
<${InspirationDetail} image=${current} index=${selected} onDelete=${handleDelete} />
|
|
861
|
+
</div>
|
|
862
|
+
`;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// ---------------------------------------------------------------------------
|
|
866
|
+
// App
|
|
867
|
+
// ---------------------------------------------------------------------------
|
|
868
|
+
|
|
869
|
+
function App() {
|
|
870
|
+
const [tab, setTab] = useState('fonts');
|
|
871
|
+
const fonts = useFonts();
|
|
872
|
+
const inspiration = useInspiration();
|
|
873
|
+
const toast = useToast();
|
|
874
|
+
|
|
875
|
+
return html`
|
|
876
|
+
<div class="header">
|
|
877
|
+
<div class="tabs">
|
|
878
|
+
<button class="tab ${tab === 'fonts' ? 'active' : ''}" onClick=${() => setTab('fonts')}>Fonts${fonts.data ? ` (${fonts.data.fonts.length})` : ''}</button>
|
|
879
|
+
<button class="tab ${tab === 'pairings' ? 'active' : ''}" onClick=${() => setTab('pairings')}>Pairings${fonts.data ? ` (${fonts.data.pairings.length})` : ''}</button>
|
|
880
|
+
<button class="tab ${tab === 'inspiration' ? 'active' : ''}" onClick=${() => setTab('inspiration')}>Inspiration${inspiration.data ? ` (${inspiration.data.images.length})` : ''}</button>
|
|
881
|
+
</div>
|
|
882
|
+
${tab === 'inspiration' ? html`
|
|
883
|
+
<div style="margin-left: auto;">
|
|
884
|
+
<${AddInspirationForm} onAdd=${inspiration.addImage} onAnalyze=${inspiration.analyze} toast=${toast} />
|
|
885
|
+
</div>
|
|
886
|
+
` : null}
|
|
887
|
+
</div>
|
|
888
|
+
<div class="content">
|
|
889
|
+
${tab === 'fonts' ? html`<${FontsTab} fonts=${fonts} />`
|
|
890
|
+
: tab === 'pairings' ? html`<${PairingsTab} fonts=${fonts} />`
|
|
891
|
+
: html`<${InspirationTab} inspiration=${inspiration} />`
|
|
892
|
+
}
|
|
893
|
+
</div>
|
|
894
|
+
<${toast.Toast} />
|
|
895
|
+
`;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
render(html`<${App} />`, document.getElementById('app'));
|
|
899
|
+
</script>
|
|
900
|
+
</body>
|
|
901
|
+
</html>
|