@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.
Files changed (54) hide show
  1. package/dist/headless.js +578 -393
  2. package/dist/index.js +652 -385
  3. package/dist/prompt/sources/llms.txt +1618 -0
  4. package/dist/prompt/static/instructions.md +1 -1
  5. package/dist/prompt/static/team.md +1 -1
  6. package/dist/subagents/.notes-background-agents.md +60 -48
  7. package/dist/subagents/browserAutomation/prompt.md +14 -11
  8. package/dist/subagents/designExpert/data/sources/dev/index.html +901 -0
  9. package/dist/subagents/designExpert/data/sources/dev/serve.mjs +244 -0
  10. package/dist/subagents/designExpert/data/sources/dev/specimens-fonts.html +126 -0
  11. package/dist/subagents/designExpert/data/sources/dev/specimens-pairings.html +114 -0
  12. package/dist/subagents/designExpert/data/{fonts.json → sources/fonts.json} +0 -97
  13. package/dist/subagents/designExpert/data/sources/inspiration.json +392 -0
  14. package/dist/subagents/designExpert/prompt.md +36 -12
  15. package/dist/subagents/designExpert/prompts/animation.md +14 -6
  16. package/dist/subagents/designExpert/prompts/color.md +25 -5
  17. package/dist/subagents/designExpert/prompts/{icons.md → components.md} +17 -5
  18. package/dist/subagents/designExpert/prompts/frontend-design-notes.md +17 -122
  19. package/dist/subagents/designExpert/prompts/identity.md +15 -61
  20. package/dist/subagents/designExpert/prompts/images.md +35 -10
  21. package/dist/subagents/designExpert/prompts/layout.md +14 -9
  22. package/dist/subagents/designExpert/prompts/typography.md +39 -0
  23. package/package.json +2 -2
  24. package/dist/actions/buildFromInitialSpec.md +0 -15
  25. package/dist/actions/publish.md +0 -12
  26. package/dist/actions/sync.md +0 -19
  27. package/dist/compiled/README.md +0 -100
  28. package/dist/compiled/auth.md +0 -77
  29. package/dist/compiled/design.md +0 -251
  30. package/dist/compiled/dev-and-deploy.md +0 -69
  31. package/dist/compiled/interfaces.md +0 -238
  32. package/dist/compiled/manifest.md +0 -107
  33. package/dist/compiled/media-cdn.md +0 -51
  34. package/dist/compiled/methods.md +0 -225
  35. package/dist/compiled/msfm.md +0 -222
  36. package/dist/compiled/platform.md +0 -105
  37. package/dist/compiled/scenarios.md +0 -103
  38. package/dist/compiled/sdk-actions.md +0 -146
  39. package/dist/compiled/tables.md +0 -263
  40. package/dist/static/authoring.md +0 -101
  41. package/dist/static/coding.md +0 -29
  42. package/dist/static/identity.md +0 -1
  43. package/dist/static/instructions.md +0 -31
  44. package/dist/static/intake.md +0 -44
  45. package/dist/static/lsp.md +0 -4
  46. package/dist/static/projectContext.ts +0 -160
  47. package/dist/static/team.md +0 -39
  48. package/dist/subagents/designExpert/data/inspiration.json +0 -392
  49. package/dist/subagents/designExpert/prompts/instructions.md +0 -18
  50. /package/dist/subagents/designExpert/data/{compile-font-descriptions.sh → sources/compile-font-descriptions.sh} +0 -0
  51. /package/dist/subagents/designExpert/data/{compile-inspiration.sh → sources/compile-inspiration.sh} +0 -0
  52. /package/dist/subagents/designExpert/data/{inspiration.raw.json → sources/inspiration.raw.json} +0 -0
  53. /package/dist/subagents/designExpert/{prompts/tool-prompts → data/sources/prompts}/design-analysis.md +0 -0
  54. /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>