design-brain-memory 0.9.2 → 0.9.4

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 (124) hide show
  1. package/README.md +273 -0
  2. package/dist/agentBrowser.js +1 -1
  3. package/dist/agentBrowser.js.map +1 -1
  4. package/dist/aggregate.d.ts +9 -0
  5. package/dist/aggregate.js +53 -0
  6. package/dist/aggregate.js.map +1 -0
  7. package/dist/batch.d.ts +16 -0
  8. package/dist/batch.js +44 -0
  9. package/dist/batch.js.map +1 -0
  10. package/dist/cli.js +250 -216
  11. package/dist/cli.js.map +1 -1
  12. package/dist/commands.d.ts +60 -1
  13. package/dist/commands.js +323 -10
  14. package/dist/commands.js.map +1 -1
  15. package/dist/compare.d.ts +33 -0
  16. package/dist/compare.js +83 -0
  17. package/dist/compare.js.map +1 -0
  18. package/dist/componentGraph.d.ts +22 -0
  19. package/dist/componentGraph.js +106 -0
  20. package/dist/componentGraph.js.map +1 -0
  21. package/dist/contextLayer.d.ts +12 -0
  22. package/dist/contextLayer.js +263 -0
  23. package/dist/contextLayer.js.map +1 -0
  24. package/dist/cssInJs.d.ts +9 -0
  25. package/dist/cssInJs.js +124 -0
  26. package/dist/cssInJs.js.map +1 -0
  27. package/dist/extractFromUrl.d.ts +8 -0
  28. package/dist/extractFromUrl.js +104 -355
  29. package/dist/extractFromUrl.js.map +1 -1
  30. package/dist/graphView.d.ts +21 -0
  31. package/dist/graphView.js +492 -0
  32. package/dist/graphView.js.map +1 -0
  33. package/dist/index.d.ts +26 -11
  34. package/dist/index.js +17 -10
  35. package/dist/index.js.map +1 -1
  36. package/dist/knowledge.d.ts +20 -0
  37. package/dist/knowledge.js +208 -0
  38. package/dist/knowledge.js.map +1 -0
  39. package/dist/liveView.d.ts +15 -0
  40. package/dist/liveView.js +475 -0
  41. package/dist/liveView.js.map +1 -0
  42. package/dist/llm.js +1 -9
  43. package/dist/llm.js.map +1 -1
  44. package/dist/moodboard.d.ts +3 -0
  45. package/dist/moodboard.js +152 -0
  46. package/dist/moodboard.js.map +1 -0
  47. package/dist/persona.d.ts +2 -2
  48. package/dist/persona.js +82 -210
  49. package/dist/persona.js.map +1 -1
  50. package/dist/query.js +1 -6
  51. package/dist/query.js.map +1 -1
  52. package/dist/render.d.ts +2 -10
  53. package/dist/render.js +80 -175
  54. package/dist/render.js.map +1 -1
  55. package/dist/reviewChecklist.d.ts +17 -0
  56. package/dist/reviewChecklist.js +126 -0
  57. package/dist/reviewChecklist.js.map +1 -0
  58. package/dist/scan.d.ts +19 -7
  59. package/dist/scan.js +132 -374
  60. package/dist/scan.js.map +1 -1
  61. package/dist/scorecard.d.ts +53 -0
  62. package/dist/scorecard.js +325 -0
  63. package/dist/scorecard.js.map +1 -0
  64. package/dist/skillPrompt.d.ts +1 -3
  65. package/dist/skillPrompt.js +22 -148
  66. package/dist/skillPrompt.js.map +1 -1
  67. package/dist/store.d.ts +2 -2
  68. package/dist/store.js +7 -9
  69. package/dist/store.js.map +1 -1
  70. package/dist/styleDictionary.d.ts +16 -0
  71. package/dist/styleDictionary.js +89 -0
  72. package/dist/styleDictionary.js.map +1 -0
  73. package/dist/svg.d.ts +5 -0
  74. package/dist/svg.js +162 -0
  75. package/dist/svg.js.map +1 -0
  76. package/dist/systemDiff.d.ts +28 -0
  77. package/dist/systemDiff.js +107 -0
  78. package/dist/systemDiff.js.map +1 -0
  79. package/dist/tailwind.d.ts +2 -0
  80. package/dist/tailwind.js +122 -0
  81. package/dist/tailwind.js.map +1 -0
  82. package/dist/taste.d.ts +3 -2
  83. package/dist/taste.js +349 -536
  84. package/dist/taste.js.map +1 -1
  85. package/dist/tasteRenderer.d.ts +2 -3
  86. package/dist/tasteRenderer.js +123 -119
  87. package/dist/tasteRenderer.js.map +1 -1
  88. package/dist/tokenNaming.d.ts +5 -0
  89. package/dist/tokenNaming.js +229 -0
  90. package/dist/tokenNaming.js.map +1 -0
  91. package/dist/tokens.d.ts +17 -0
  92. package/dist/tokens.js +44 -0
  93. package/dist/tokens.js.map +1 -0
  94. package/dist/trends.d.ts +12 -0
  95. package/dist/trends.js +178 -0
  96. package/dist/trends.js.map +1 -0
  97. package/dist/types.d.ts +47 -101
  98. package/dist/wiki.d.ts +10 -0
  99. package/dist/wiki.js +346 -0
  100. package/dist/wiki.js.map +1 -0
  101. package/dist/writingStyle.d.ts +38 -0
  102. package/dist/writingStyle.js +224 -0
  103. package/dist/writingStyle.js.map +1 -0
  104. package/package.json +5 -4
  105. package/dist/classify.d.ts +0 -21
  106. package/dist/classify.js +0 -205
  107. package/dist/classify.js.map +0 -1
  108. package/dist/scanRenderer.d.ts +0 -2
  109. package/dist/scanRenderer.js +0 -155
  110. package/dist/scanRenderer.js.map +0 -1
  111. package/dist/tasteDiff.d.ts +0 -19
  112. package/dist/tasteDiff.js +0 -340
  113. package/dist/tasteDiff.js.map +0 -1
  114. package/dist/tasteGenerate.d.ts +0 -12
  115. package/dist/tasteGenerate.js +0 -140
  116. package/dist/tasteGenerate.js.map +0 -1
  117. package/dist/tasteRefine.d.ts +0 -13
  118. package/dist/tasteRefine.js +0 -351
  119. package/dist/tasteRefine.js.map +0 -1
  120. package/dist/theatrical.d.ts +0 -5
  121. package/dist/theatrical.js +0 -258
  122. package/dist/theatrical.js.map +0 -1
  123. package/skills/SKILL.md +0 -36
  124. package/skills/design-brain/SKILL.md +0 -77
package/dist/taste.js CHANGED
@@ -1,598 +1,411 @@
1
- import os from 'node:os';
2
- import path from 'node:path';
3
- import { ingestInspiration } from './commands.js';
4
- import { captureDesignFromUrl } from './extractFromUrl.js';
5
- import { runDesignLlm } from './llm.js';
1
+ import { aggregateColors, aggregateTypography, aggregateComponents, aggregateMotion } from './aggregate.js';
2
+ import { designAnalysisToScanTokens, computeScore, looksLikeUrl, normalizeToUrl, hueFromHex, saturationFromHex, lightnessFromHex } from './scan.js';
6
3
  import { assignPersona } from './persona.js';
7
- import { aggregateColors, aggregateComponents, aggregateMotion, aggregateTypography } from './render.js';
8
- import { computeScore, designAnalysisToScanTokens, looksLikeUrl, normalizeToUrl } from './scan.js';
9
- import { ensureProject, loadDatabase, saveDatabase, loadTasteProfile, saveTasteProfile } from './store.js';
10
- import { theatricalScan } from './theatrical.js';
11
- import { detectConflicts } from './tasteRefine.js';
12
- import { nowIso, unique } from './util.js';
13
- function asAnimationToken(token) {
14
- return 'library' in token && 'motionIntent' in token;
15
- }
16
- function parseDurationMs(value) {
17
- const matches = value.match(/(\d+(?:\.\d+)?)\s*(ms|s)\b/gi) ?? [];
18
- return matches
19
- .map((match) => {
20
- const parsed = match.match(/(\d+(?:\.\d+)?)\s*(ms|s)\b/i);
21
- if (!parsed) {
22
- return null;
23
- }
24
- const numeric = Number.parseFloat(parsed[1]);
25
- if (!Number.isFinite(numeric)) {
26
- return null;
27
- }
28
- return parsed[2].toLowerCase() === 's' ? numeric * 1000 : numeric;
29
- })
30
- .filter((value) => value !== null);
31
- }
32
- function parseNumericValues(value) {
33
- if (!value) {
34
- return [];
35
- }
36
- return (value.match(/-?\d+(?:\.\d+)?/g) ?? [])
37
- .map((entry) => Number.parseFloat(entry))
38
- .filter((entry) => Number.isFinite(entry));
39
- }
40
- function parseFontSizeToPx(value) {
41
- const text = value.trim().toLowerCase();
42
- const match = text.match(/^(\d+(?:\.\d+)?)(px|rem|em)$/);
43
- if (!match) {
44
- return null;
45
- }
46
- const numeric = Number.parseFloat(match[1]);
47
- if (!Number.isFinite(numeric)) {
48
- return null;
49
- }
50
- if (match[2] === 'px') {
51
- return numeric;
52
- }
53
- return numeric * 16;
54
- }
55
- function parseWeightValue(weight) {
56
- const normalized = weight.trim().toLowerCase();
57
- if (/^\d+$/.test(normalized)) {
58
- return Number.parseInt(normalized, 10);
59
- }
60
- const map = {
61
- normal: 400,
62
- medium: 500,
63
- semibold: 600,
64
- bold: 700,
65
- };
66
- return map[normalized] ?? null;
67
- }
68
- function formatDuration(value) {
69
- return `${Math.round(value)}ms`;
70
- }
71
- function resolveColorRole(index) {
72
- const roles = ['primary', 'background', 'accent', 'text', 'surface'];
73
- return roles[index] ?? `color-${index + 1}`;
74
- }
75
- function sourceForColor(hex, inspirations) {
76
- const normalized = hex.toUpperCase();
77
- let bestSource = 'aggregate';
78
- let bestCount = -1;
79
- for (const inspiration of inspirations) {
80
- const match = inspiration.analysis.colors.find((color) => color.hex.toUpperCase() === normalized);
81
- if (match && match.count > bestCount) {
82
- bestCount = match.count;
83
- bestSource = inspiration.url
84
- ? (() => {
85
- try {
86
- return new URL(inspiration.url).hostname;
87
- }
88
- catch {
89
- return inspiration.url;
90
- }
91
- })()
92
- : inspiration.id;
93
- }
94
- }
95
- return bestSource;
96
- }
97
- function hexToRgb(hex) {
98
- const clean = hex.replace('#', '').trim();
99
- if (!/^[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(clean)) {
100
- return null;
101
- }
102
- const full = clean.length === 3
103
- ? `${clean[0]}${clean[0]}${clean[1]}${clean[1]}${clean[2]}${clean[2]}`
104
- : clean;
105
- return {
106
- r: Number.parseInt(full.slice(0, 2), 16),
107
- g: Number.parseInt(full.slice(2, 4), 16),
108
- b: Number.parseInt(full.slice(4, 6), 16),
109
- };
110
- }
111
- function rgbToHsl(rgb) {
112
- const r = rgb.r / 255;
113
- const g = rgb.g / 255;
114
- const b = rgb.b / 255;
115
- const max = Math.max(r, g, b);
116
- const min = Math.min(r, g, b);
117
- const l = (max + min) / 2;
118
- if (max === min) {
119
- return { h: 0, s: 0, l };
120
- }
121
- const d = max - min;
122
- const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
123
- let h = 0;
124
- if (max === r) {
125
- h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
126
- }
127
- else if (max === g) {
128
- h = ((b - r) / d + 2) / 6;
129
- }
130
- else {
131
- h = ((r - g) / d + 4) / 6;
132
- }
133
- return { h: h * 360, s, l };
134
- }
135
- function hueDelta(a, b) {
136
- const direct = Math.abs(a - b);
137
- return Math.min(direct, 360 - direct);
138
- }
4
+ import { loadDatabase, ensureProject, saveTasteProfile, loadTasteProfile } from './store.js';
5
+ import { enrichWithLlm } from './llm.js';
6
+ import { ingestInspiration } from './commands.js';
7
+ import { nowIso } from './util.js';
8
+ // ── Derive functions ───────────────────────────────────────
139
9
  function classifyHarmony(hues) {
140
- if (hues.length <= 1) {
10
+ if (hues.length <= 1)
141
11
  return 'monochromatic';
142
- }
143
- const deltas = [];
144
- for (let i = 0; i < hues.length; i += 1) {
145
- for (let j = i + 1; j < hues.length; j += 1) {
146
- deltas.push(hueDelta(hues[i], hues[j]));
147
- }
148
- }
149
- if (deltas.some((value) => value >= 150 && value <= 210)) {
150
- return 'complementary';
151
- }
152
- const averageDelta = deltas.reduce((sum, value) => sum + value, 0) / deltas.length;
153
- if (averageDelta <= 45) {
12
+ const sorted = [...hues].sort((a, b) => a - b);
13
+ const gaps = [];
14
+ for (let i = 1; i < sorted.length; i++) {
15
+ gaps.push(sorted[i] - sorted[i - 1]);
16
+ }
17
+ gaps.push(360 - sorted[sorted.length - 1] + sorted[0]);
18
+ const maxGap = Math.max(...gaps);
19
+ const minGap = Math.min(...gaps);
20
+ const avgGap = 360 / hues.length;
21
+ const spread = maxGap - minGap;
22
+ if (spread < 40)
154
23
  return 'analogous';
155
- }
156
- return 'triadic';
24
+ if (hues.length === 2 && Math.abs(gaps[0] - 180) < 30)
25
+ return 'complementary';
26
+ if (hues.length === 3 && Math.abs(avgGap - 120) < 25)
27
+ return 'triadic';
28
+ if (maxGap > 200)
29
+ return 'split-complementary';
30
+ return 'analogous';
157
31
  }
158
32
  function deriveColorPreference(colors, inspirations) {
159
- const palette = colors
160
- .slice(0, 5)
161
- .map((color, index) => ({
162
- hex: color.hex.toUpperCase(),
163
- role: resolveColorRole(index),
164
- source: sourceForColor(color.hex, inspirations),
165
- }));
166
- if (palette.length === 0) {
167
- palette.push({ hex: '#111111', role: 'primary', source: 'default' }, { hex: '#FFFFFF', role: 'surface', source: 'default' });
33
+ const palette = [];
34
+ const roles = ['primary', 'secondary', 'accent', 'background', 'text', 'surface'];
35
+ for (let i = 0; i < Math.min(colors.length, roles.length); i++) {
36
+ const color = colors[i];
37
+ const source = inspirations.find((ins) => ins.analysis.colors.some((c) => c.hex.toUpperCase() === color.hex.toUpperCase()));
38
+ palette.push({
39
+ hex: color.hex,
40
+ role: roles[i],
41
+ source: source?.url ?? source?.name ?? 'unknown',
42
+ });
168
43
  }
169
- const hslValues = palette
170
- .map((entry) => hexToRgb(entry.hex))
171
- .filter((entry) => entry !== null)
172
- .map(rgbToHsl);
173
- const hues = hslValues.map((value) => value.h);
174
- const sats = hslValues.map((value) => value.s);
175
- const lights = hslValues.map((value) => value.l);
176
- const hueRange = {
177
- min: Math.round(Math.min(...(hues.length > 0 ? hues : [0]))),
178
- max: Math.round(Math.max(...(hues.length > 0 ? hues : [0]))),
179
- };
180
- const avgSat = sats.length > 0 ? sats.reduce((sum, value) => sum + value, 0) / sats.length : 0;
181
- const avgLight = lights.length > 0 ? lights.reduce((sum, value) => sum + value, 0) / lights.length : 0;
44
+ const validHexes = colors
45
+ .filter((c) => c.hex.length === 7)
46
+ .slice(0, 12);
47
+ const hues = [...new Set(validHexes.map((c) => Math.round(hueFromHex(c.hex) / 30) * 30))];
48
+ const saturations = validHexes.map((c) => saturationFromHex(c.hex));
49
+ const lightnesses = validHexes.map((c) => lightnessFromHex(c.hex));
50
+ const avgSat = saturations.length > 0
51
+ ? saturations.reduce((a, b) => a + b, 0) / saturations.length
52
+ : 0.5;
53
+ const avgLight = lightnesses.length > 0
54
+ ? lightnesses.reduce((a, b) => a + b, 0) / lightnesses.length
55
+ : 0.5;
56
+ const allHues = validHexes.map((c) => hueFromHex(c.hex));
57
+ const minHue = allHues.length > 0 ? Math.min(...allHues) : 0;
58
+ const maxHue = allHues.length > 0 ? Math.max(...allHues) : 360;
182
59
  return {
183
60
  palette,
184
61
  harmony: classifyHarmony(hues),
185
- hueRange,
186
- saturationBias: avgSat >= 0.62 ? 'vibrant' : avgSat <= 0.35 ? 'muted' : 'neutral',
187
- lightnessBias: avgLight >= 0.7 ? 'light' : avgLight <= 0.33 ? 'dark' : 'balanced',
62
+ hueRange: { min: Math.round(minHue), max: Math.round(maxHue) },
63
+ saturationBias: avgSat > 0.6 ? 'vibrant' : avgSat < 0.3 ? 'muted' : 'neutral',
64
+ lightnessBias: avgLight > 0.65 ? 'light' : avgLight < 0.35 ? 'dark' : 'balanced',
188
65
  };
189
66
  }
190
67
  function deriveTypographyPreference(typography) {
191
- const familyCounts = new Map();
192
- for (const token of typography) {
193
- familyCounts.set(token.fontFamily, (familyCounts.get(token.fontFamily) ?? 0) + token.count);
194
- }
195
- const families = [...familyCounts.entries()]
196
- .sort((a, b) => b[1] - a[1])
197
- .map((entry) => entry[0]);
198
- const primaryFont = families[0] ?? 'Inter';
199
- const secondaryFont = families[1] ?? null;
200
- const sizeSet = unique(typography
201
- .map((token) => token.fontSize.trim())
202
- .filter((size) => size.length > 0));
203
- const sizes = sizeSet.sort((a, b) => {
204
- const pa = parseFontSizeToPx(a);
205
- const pb = parseFontSizeToPx(b);
206
- if (pa === null && pb === null) {
207
- return a.localeCompare(b);
208
- }
209
- if (pa === null) {
210
- return 1;
211
- }
212
- if (pb === null) {
213
- return -1;
214
- }
215
- return pa - pb;
216
- });
217
- const pxSizes = sizes
218
- .map(parseFontSizeToPx)
219
- .filter((value) => value !== null)
68
+ const familyCount = new Map();
69
+ for (const t of typography) {
70
+ const fam = t.fontFamily.toLowerCase();
71
+ familyCount.set(fam, (familyCount.get(fam) ?? 0) + t.count);
72
+ }
73
+ const sorted = [...familyCount.entries()].sort((a, b) => b[1] - a[1]);
74
+ const primaryFont = sorted[0]?.[0] ?? 'system-ui';
75
+ const secondaryFont = sorted[1]?.[0] ?? null;
76
+ const sizes = [...new Set(typography.map((t) => t.fontSize))];
77
+ const numericSizes = sizes
78
+ .map((s) => parseFloat(s))
79
+ .filter((n) => !isNaN(n))
220
80
  .sort((a, b) => a - b);
221
- const ratios = [];
222
- for (let i = 1; i < pxSizes.length; i += 1) {
223
- if (pxSizes[i - 1] > 0) {
224
- ratios.push(pxSizes[i] / pxSizes[i - 1]);
81
+ let scaleType = 'custom';
82
+ if (numericSizes.length >= 3) {
83
+ const ratios = [];
84
+ for (let i = 1; i < numericSizes.length; i++) {
85
+ ratios.push(numericSizes[i] / numericSizes[i - 1]);
225
86
  }
226
- }
227
- const avgRatio = ratios.length > 0
228
- ? ratios.reduce((sum, value) => sum + value, 0) / ratios.length
229
- : 0;
230
- const ratioVariance = ratios.length > 0
231
- ? ratios.reduce((sum, value) => sum + Math.abs(value - avgRatio), 0) / ratios.length
232
- : 1;
233
- const weightValues = typography
234
- .map((token) => token.fontWeight.trim())
235
- .filter((weight) => weight.length > 0);
236
- const numericWeights = weightValues
237
- .map(parseWeightValue)
238
- .filter((value) => value !== null)
239
- .sort((a, b) => a - b);
240
- const minWeight = numericWeights[0]?.toString() ?? weightValues[0] ?? '400';
241
- const maxWeight = numericWeights[numericWeights.length - 1]?.toString() ?? weightValues.at(-1) ?? '700';
87
+ const avgRatio = ratios.reduce((a, b) => a + b, 0) / ratios.length;
88
+ if (Math.abs(avgRatio - 1.25) < 0.1)
89
+ scaleType = 'major-third';
90
+ else if (Math.abs(avgRatio - 1.333) < 0.1)
91
+ scaleType = 'perfect-fourth';
92
+ else if (Math.abs(avgRatio - 1.5) < 0.1)
93
+ scaleType = 'perfect-fifth';
94
+ else if (Math.abs(avgRatio - 1.618) < 0.15)
95
+ scaleType = 'golden-ratio';
96
+ else
97
+ scaleType = 'linear';
98
+ }
99
+ const weights = typography.map((t) => t.fontWeight).filter((w) => w !== 'unknown');
100
+ const numericWeights = weights.map((w) => parseInt(w, 10)).filter((n) => !isNaN(n)).sort((a, b) => a - b);
242
101
  return {
243
102
  primaryFont,
244
103
  secondaryFont,
245
- scaleType: sizes.length <= 2 ? 'tailwind-default' : ratioVariance <= 0.2 ? 'modular' : 'custom',
246
- sizes,
104
+ scaleType,
105
+ sizes: sizes.slice(0, 8),
247
106
  weightRange: {
248
- min: minWeight,
249
- max: maxWeight,
107
+ min: numericWeights[0]?.toString() ?? '400',
108
+ max: numericWeights[numericWeights.length - 1]?.toString() ?? '700',
250
109
  },
251
110
  };
252
111
  }
253
112
  function deriveSpacingPreference(components) {
254
- const values = components
255
- .flatMap((component) => [
256
- component.styles.padding,
257
- component.styles.margin,
258
- component.styles.gap,
259
- component.styles.rowGap,
260
- component.styles.columnGap,
261
- ])
262
- .flatMap(parseNumericValues)
263
- .filter((value) => value > 0);
264
- const baseAligned4 = values.filter((value) => value % 4 === 0).length;
265
- const baseAligned8 = values.filter((value) => value % 8 === 0).length;
266
- const baseUnit = baseAligned8 >= baseAligned4 ? 8 : 4;
113
+ const spacingProps = [
114
+ 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
115
+ 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
116
+ 'gap', 'row-gap', 'column-gap',
117
+ ];
118
+ const values = [];
119
+ for (const comp of components) {
120
+ for (const prop of spacingProps) {
121
+ const val = comp.styles[prop];
122
+ if (!val)
123
+ continue;
124
+ const nums = val.match(/(\d+(?:\.\d+)?)/g);
125
+ if (nums) {
126
+ for (const n of nums) {
127
+ const parsed = parseFloat(n);
128
+ if (parsed > 0 && parsed < 200)
129
+ values.push(parsed);
130
+ }
131
+ }
132
+ }
133
+ }
134
+ if (values.length === 0) {
135
+ return { baseUnit: 8, scale: [4, 8, 12, 16, 24, 32, 48, 64], gridAlignmentRatio: 0 };
136
+ }
137
+ const mod4 = values.filter((v) => v % 4 === 0).length;
138
+ const mod8 = values.filter((v) => v % 8 === 0).length;
139
+ const baseUnit = mod8 / values.length > 0.6 ? 8 : 4;
140
+ const uniqueValues = [...new Set(values)].sort((a, b) => a - b);
141
+ const scale = uniqueValues
142
+ .filter((v) => v % baseUnit === 0)
143
+ .slice(0, 10);
267
144
  const gridAlignmentRatio = values.length > 0
268
- ? values.filter((value) => value % baseUnit === 0).length / values.length
269
- : 1;
270
- const scale = unique(values.map((value) => Math.round(value)))
271
- .sort((a, b) => a - b)
272
- .slice(0, 12);
273
- const fallbackScale = baseUnit === 8
274
- ? [4, 8, 12, 16, 24, 32, 48, 64]
275
- : [4, 8, 12, 16, 20, 24, 32, 40];
145
+ ? Math.round((mod4 / values.length) * 100) / 100
146
+ : 0;
276
147
  return {
277
148
  baseUnit,
278
- scale: scale.length > 0 ? scale : fallbackScale,
279
- gridAlignmentRatio: Number(gridAlignmentRatio.toFixed(2)),
149
+ scale: scale.length > 0 ? scale : [4, 8, 12, 16, 24, 32, 48, 64],
150
+ gridAlignmentRatio,
280
151
  };
281
152
  }
282
153
  function deriveMotionPreference(motion) {
283
- const easings = new Map();
284
- const durations = [];
285
- for (const token of motion) {
286
- if (asAnimationToken(token)) {
287
- if (token.timing) {
288
- durations.push(token.timing.duration);
289
- const easing = token.timing.easing.trim();
290
- if (easing) {
291
- easings.set(easing, (easings.get(easing) ?? 0) + token.count);
292
- }
154
+ if (motion.length === 0) {
155
+ return { easing: 'ease', durations: [], intensity: 'none' };
156
+ }
157
+ const easingCount = new Map();
158
+ const durationCount = new Map();
159
+ for (const m of motion) {
160
+ // Extract easing from raw string first (structured fields may have split cubic-bezier on commas)
161
+ let easingFromRaw = null;
162
+ if (m.transition && m.transition !== 'none' && m.transition !== 'all') {
163
+ const cubicMatch = m.transition.match(/cubic-bezier\([^)]+\)/);
164
+ if (cubicMatch) {
165
+ easingFromRaw = cubicMatch[0];
166
+ }
167
+ else {
168
+ const keyword = m.transition.match(/\b(ease-in-out|ease-in|ease-out|ease|linear)\b/);
169
+ if (keyword)
170
+ easingFromRaw = keyword[0];
293
171
  }
294
- continue;
295
- }
296
- for (const duration of parseDurationMs(token.transition)) {
297
- durations.push(duration);
298
172
  }
299
- for (const duration of parseDurationMs(token.animation)) {
300
- durations.push(duration);
173
+ if (easingFromRaw) {
174
+ easingCount.set(easingFromRaw, (easingCount.get(easingFromRaw) ?? 0) + m.count);
301
175
  }
302
- const easingCandidates = token.transition.match(/(?:ease(?:-in|-out|-in-out)?|linear|cubic-bezier\([^)]+\))/g) ?? [];
303
- for (const easing of easingCandidates) {
304
- easings.set(easing, (easings.get(easing) ?? 0) + token.count);
176
+ else {
177
+ // Fall back to structured fields only if raw string didn't yield a result
178
+ for (const t of m.transitions ?? []) {
179
+ if (t.timingFunction && t.timingFunction.length > 3) {
180
+ easingCount.set(t.timingFunction, (easingCount.get(t.timingFunction) ?? 0) + m.count);
181
+ }
182
+ }
183
+ for (const a of m.animations ?? []) {
184
+ if (a.timingFunction && a.timingFunction.length > 3) {
185
+ easingCount.set(a.timingFunction, (easingCount.get(a.timingFunction) ?? 0) + m.count);
186
+ }
187
+ }
305
188
  }
306
- }
307
- const dominantEasing = [...easings.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'ease';
308
- const uniqueDurations = unique(durations.map((value) => Math.round(value))).sort((a, b) => a - b);
309
- const durationLabels = uniqueDurations.slice(0, 6).map(formatDuration);
310
- const count = motion.length;
311
- const intensity = count === 0
312
- ? 'none'
313
- : count <= 12
314
- ? 'subtle'
315
- : count <= 40
316
- ? 'moderate'
317
- : 'expressive';
318
- return {
319
- easing: dominantEasing,
320
- durations: durationLabels.length > 0 ? durationLabels : ['200ms'],
321
- intensity,
322
- };
323
- }
324
- function deriveComponentPreference(components) {
325
- const radiusValues = components
326
- .flatMap((component) => parseNumericValues(component.styles.borderRadius))
327
- .filter((value) => value >= 0)
328
- .sort((a, b) => a - b);
329
- const medianRadius = radiusValues.length > 0
330
- ? radiusValues[Math.floor(radiusValues.length / 2)]
331
- : 8;
332
- const shadows = components
333
- .map((component) => component.styles.boxShadow?.trim())
334
- .filter((shadow) => Boolean(shadow && shadow !== 'none'));
335
- const noneCount = components.length - shadows.length;
336
- const shadowStyle = shadows.length === 0
337
- ? 'none'
338
- : shadows.length <= noneCount
339
- ? 'subtle'
340
- : shadows.some((shadow) => /(?:\b2\dpx\b|\b3\dpx\b|\b4\dpx\b|rgba?\([^)]*,\s*0\.[45-9])/i.test(shadow))
341
- ? 'dramatic'
342
- : 'elevated';
343
- return {
344
- cherryPicks: [],
345
- borderRadius: `${Math.round(medianRadius)}px`,
346
- shadowStyle,
347
- };
348
- }
349
- function buildAggregateTokens(inspirations) {
350
- const colors = aggregateColors(inspirations);
351
- const typography = aggregateTypography(inspirations);
352
- const components = aggregateComponents(inspirations);
353
- const motion = aggregateMotion(inspirations);
354
- const transitionValues = motion.flatMap((token) => {
355
- if (asAnimationToken(token)) {
356
- if (!token.timing) {
357
- return [];
189
+ // Durations: prefer structured, fall back to raw
190
+ let gotDuration = false;
191
+ for (const t of m.transitions ?? []) {
192
+ if (t.duration && t.duration !== '0s') {
193
+ durationCount.set(t.duration, (durationCount.get(t.duration) ?? 0) + m.count);
194
+ gotDuration = true;
358
195
  }
359
- return [`${token.motionIntent} ${token.timing.duration}ms ${token.timing.easing}`];
360
196
  }
361
- const values = [];
362
- if (token.transition && token.transition !== 'all 0s ease 0s') {
363
- values.push(token.transition);
197
+ for (const a of m.animations ?? []) {
198
+ if (a.duration && a.duration !== '0s') {
199
+ durationCount.set(a.duration, (durationCount.get(a.duration) ?? 0) + m.count);
200
+ gotDuration = true;
201
+ }
364
202
  }
365
- if (token.animation && token.animation !== 'none') {
366
- values.push(token.animation);
203
+ if (!gotDuration && m.transition && m.transition !== 'none' && m.transition !== 'all') {
204
+ const durMatch = m.transition.match(/([\d.]+)(m?s)\b/);
205
+ if (durMatch) {
206
+ durationCount.set(`${durMatch[1]}${durMatch[2]}`, (durationCount.get(`${durMatch[1]}${durMatch[2]}`) ?? 0) + m.count);
207
+ }
367
208
  }
368
- return values;
369
- });
370
- const spacingValues = components.flatMap((component) => [
371
- component.styles.padding,
372
- component.styles.margin,
373
- component.styles.gap,
374
- component.styles.rowGap,
375
- component.styles.columnGap,
376
- ].filter(Boolean));
377
- const cssVariableCount = inspirations.reduce((count, inspiration) => (count + Object.keys(inspiration.analysis.cssVariables).length), 0);
378
- return {
379
- colors: colors.map((color) => color.hex),
380
- fontFamilies: unique(typography.map((token) => token.fontFamily.toLowerCase())),
381
- fontSizes: unique(typography.map((token) => token.fontSize)),
382
- transitions: transitionValues,
383
- spacingValues,
384
- cssVariableCount,
385
- framework: null,
386
- };
387
- }
388
- function extractFirstJsonObject(raw) {
389
- const trimmed = raw.trim();
390
- if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
391
- return trimmed;
392
- }
393
- const start = raw.indexOf('{');
394
- const end = raw.lastIndexOf('}');
395
- if (start >= 0 && end > start) {
396
- return raw.slice(start, end + 1);
397
209
  }
398
- throw new Error('No JSON object found in LLM response');
210
+ const topEasing = [...easingCount.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'ease';
211
+ const durations = [...durationCount.entries()]
212
+ .sort((a, b) => b[1] - a[1])
213
+ .slice(0, 4)
214
+ .map(([d]) => d);
215
+ const durationMs = durations.map((d) => {
216
+ const match = d.match(/([\d.]+)(s|ms)/);
217
+ if (!match)
218
+ return 300;
219
+ return match[2] === 's' ? parseFloat(match[1]) * 1000 : parseFloat(match[1]);
220
+ });
221
+ // Filter out animation loops (>10s) for intensity classification
222
+ const uiDurationMs = durationMs.filter((ms) => ms <= 10_000);
223
+ const avgDuration = uiDurationMs.length > 0
224
+ ? uiDurationMs.reduce((a, b) => a + b, 0) / uiDurationMs.length
225
+ : 0;
226
+ let intensity;
227
+ if (motion.length <= 3 && avgDuration < 200)
228
+ intensity = 'subtle';
229
+ else if (motion.length <= 10 && avgDuration < 400)
230
+ intensity = 'moderate';
231
+ else
232
+ intensity = 'expressive';
233
+ return { easing: topEasing, durations, intensity };
399
234
  }
400
- async function maybeGenerateNarrative(input) {
401
- if (!input.llm) {
402
- return {};
235
+ function deriveComponentPreference(components) {
236
+ const radiusValues = [];
237
+ const shadowValues = [];
238
+ for (const comp of components) {
239
+ const br = comp.styles['border-radius'];
240
+ if (br && br !== '0px')
241
+ radiusValues.push(br);
242
+ const bs = comp.styles['box-shadow'];
243
+ if (bs && bs !== 'none')
244
+ shadowValues.push(bs);
245
+ }
246
+ const radiusCounts = new Map();
247
+ for (const r of radiusValues) {
248
+ radiusCounts.set(r, (radiusCounts.get(r) ?? 0) + 1);
249
+ }
250
+ const topRadius = [...radiusCounts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? '0px';
251
+ let shadowStyle = 'none';
252
+ if (shadowValues.length > 0) {
253
+ const hasBigShadow = shadowValues.some((s) => {
254
+ const nums = s.match(/(\d+)px/g);
255
+ return nums && nums.some((n) => parseInt(n) > 20);
256
+ });
257
+ if (hasBigShadow)
258
+ shadowStyle = 'dramatic';
259
+ else if (shadowValues.length > components.length * 0.3)
260
+ shadowStyle = 'elevated';
261
+ else
262
+ shadowStyle = 'subtle';
403
263
  }
404
- const prompt = [
405
- 'Return strict JSON with keys: narrative (string), principles (string[]).',
406
- 'narrative should be 2-3 sentences and concise.',
407
- 'principles should contain 3-6 short imperative statements.',
408
- '',
409
- `Persona: ${input.profile.persona.name} — "${input.profile.persona.tagline}"`,
410
- `Palette: ${input.profile.color.palette.map((entry) => `${entry.hex} (${entry.role})`).join(', ')}`,
411
- `Typography: primary=${input.profile.typography.primaryFont}, secondary=${input.profile.typography.secondaryFont ?? 'none'}`,
412
- `Spacing: base=${input.profile.spacing.baseUnit}, scale=${input.profile.spacing.scale.join(', ')}`,
413
- `Motion: intensity=${input.profile.motion.intensity}, durations=${input.profile.motion.durations.join(', ')}, easing=${input.profile.motion.easing}`,
414
- `Shape: radius=${input.profile.components.borderRadius}, shadow=${input.profile.components.shadowStyle}`,
415
- ].join('\n');
416
- const content = await runDesignLlm({
417
- llm: input.llm,
418
- system: 'You are a design systems editor. Output JSON only.',
419
- userContent: prompt,
420
- temperature: 0.3,
421
- });
422
- const parsed = JSON.parse(extractFirstJsonObject(content));
423
264
  return {
424
- narrative: typeof parsed.narrative === 'string' ? parsed.narrative.trim() : undefined,
425
- principles: Array.isArray(parsed.principles)
426
- ? parsed.principles
427
- .map((value) => (typeof value === 'string' ? value.trim() : ''))
428
- .filter((value) => value.length > 0)
429
- .slice(0, 6)
430
- : undefined,
265
+ cherryPicks: [],
266
+ borderRadius: topRadius,
267
+ shadowStyle,
431
268
  };
432
269
  }
433
- function mergeConflicts(previous, next) {
434
- if (!previous) {
435
- return next;
436
- }
437
- const previousByKey = new Map();
438
- for (const conflict of previous.conflicts) {
439
- previousByKey.set(`${conflict.dimension}:${conflict.description}`, conflict);
270
+ // ── Conflict detection ─────────────────────────────────────
271
+ function detectConflicts(inspirations) {
272
+ const conflicts = [];
273
+ // Font conflict: different primaries across sources
274
+ const fontsBySource = new Map();
275
+ for (const ins of inspirations) {
276
+ const topFont = ins.analysis.typography
277
+ .sort((a, b) => b.count - a.count)[0]?.fontFamily;
278
+ if (topFont)
279
+ fontsBySource.set(ins.name, topFont);
280
+ }
281
+ const uniqueFonts = [...new Set(fontsBySource.values())];
282
+ if (uniqueFonts.length > 2) {
283
+ conflicts.push({
284
+ dimension: 'typography',
285
+ description: `Sources use ${uniqueFonts.length} different primary fonts: ${uniqueFonts.slice(0, 4).join(', ')}`,
286
+ options: uniqueFonts.slice(0, 4),
287
+ resolved: false,
288
+ });
440
289
  }
441
- return next.map((conflict) => {
442
- const prior = previousByKey.get(`${conflict.dimension}:${conflict.description}`);
443
- if (!prior) {
444
- return conflict;
290
+ // Color temperature conflict
291
+ const huesBySource = [];
292
+ for (const ins of inspirations) {
293
+ const hues = ins.analysis.colors
294
+ .filter((c) => c.hex.length === 7)
295
+ .slice(0, 5)
296
+ .map((c) => hueFromHex(c.hex));
297
+ if (hues.length > 0)
298
+ huesBySource.push(hues);
299
+ }
300
+ if (huesBySource.length >= 2) {
301
+ const avgHues = huesBySource.map((hs) => hs.reduce((a, b) => a + b, 0) / hs.length);
302
+ const hueSpread = Math.max(...avgHues) - Math.min(...avgHues);
303
+ if (hueSpread > 120) {
304
+ conflicts.push({
305
+ dimension: 'color',
306
+ description: `Color palettes span ${Math.round(hueSpread)}° of hue — sources have different color temperatures`,
307
+ options: ['warm bias', 'cool bias', 'mixed palette'],
308
+ resolved: false,
309
+ });
445
310
  }
446
- return {
447
- ...conflict,
448
- resolved: prior.resolved,
449
- resolvedByDecisionId: prior.resolvedByDecisionId,
450
- };
451
- });
452
- }
453
- async function analyzeUrl(options) {
454
- if (options.headed) {
455
- const result = await theatricalScan(options.url);
456
- return result.analysis;
457
311
  }
458
- const sessionName = `taste-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
459
- const screenshotPath = path.join(os.tmpdir(), `${sessionName}.png`);
460
- return captureDesignFromUrl({
461
- url: options.url,
462
- sessionName,
463
- screenshotPath,
464
- workingDir: options.rootDir,
465
- journeySteps: 1,
466
- responsiveViewports: [{ label: 'desktop', width: 1440, height: 1200 }],
467
- });
468
- }
469
- function normalizeUrls(urls) {
470
- const normalized = urls
471
- .map((url) => url.trim())
472
- .filter((url) => url.length > 0)
473
- .map((url) => {
474
- if (!looksLikeUrl(url)) {
475
- throw new Error(`Invalid URL/domain input: ${url}`);
476
- }
477
- return normalizeToUrl(url);
478
- });
479
- return unique(normalized);
312
+ return conflicts;
480
313
  }
314
+ // ── Main builder ───────────────────────────────────────────
481
315
  export async function buildTasteProfile(options) {
482
- const urls = normalizeUrls(options.urls);
483
- if (urls.length === 0) {
484
- throw new Error('At least one URL is required to build a taste profile');
485
- }
486
- const db = await loadDatabase(options.rootDir);
487
- const project = ensureProject(db, {
488
- projectId: options.projectId,
489
- projectName: options.projectName,
490
- });
491
- await saveDatabase(options.rootDir, db);
492
- const previewAnalyses = [];
493
- for (const url of urls) {
494
- previewAnalyses.push(await analyzeUrl({
495
- rootDir: options.rootDir,
496
- url,
497
- headed: Boolean(options.headed),
498
- }));
499
- }
500
- for (const url of urls) {
316
+ const { rootDir, projectId, projectName, urls, headed = false, llm } = options;
317
+ const db = await loadDatabase(rootDir);
318
+ const project = ensureProject(db, { projectId, projectName });
319
+ // Step 1-2: Scan + ingest each URL
320
+ for (const rawUrl of urls) {
321
+ const url = looksLikeUrl(rawUrl) ? normalizeToUrl(rawUrl) : rawUrl;
501
322
  await ingestInspiration({
502
- rootDir: options.rootDir,
323
+ rootDir,
503
324
  project: project.id,
504
- projectName: options.projectName,
325
+ projectName: project.name,
505
326
  url,
506
- name: url,
507
- tags: ['taste'],
327
+ tags: ['taste-source'],
328
+ skipVisuals: true,
329
+ headed,
330
+ llm,
508
331
  });
509
332
  }
510
- const latestDb = await loadDatabase(options.rootDir);
511
- const latestProject = latestDb.projects.find((entry) => entry.id === project.id);
512
- if (!latestProject) {
513
- throw new Error(`Project not found after ingestion: ${project.id}`);
333
+ // Step 3: Reload to get all inspirations
334
+ const freshDb = await loadDatabase(rootDir);
335
+ const freshProject = freshDb.projects.find((p) => p.id === project.id);
336
+ if (!freshProject)
337
+ throw new Error(`Project not found after ingest: ${project.id}`);
338
+ const inspirations = freshProject.inspirations;
339
+ if (inspirations.length === 0) {
340
+ throw new Error('No inspirations found — nothing to build taste from');
514
341
  }
515
- if (latestProject.inspirations.length === 0) {
516
- throw new Error(`No inspirations available for project: ${project.id}`);
517
- }
518
- const aggregatedColors = aggregateColors(latestProject.inspirations);
519
- const aggregatedTypography = aggregateTypography(latestProject.inspirations);
520
- const aggregatedComponents = aggregateComponents(latestProject.inspirations);
521
- const aggregatedMotion = aggregateMotion(latestProject.inspirations);
522
- const color = deriveColorPreference(aggregatedColors, latestProject.inspirations);
523
- const typography = deriveTypographyPreference(aggregatedTypography);
524
- const spacing = deriveSpacingPreference(aggregatedComponents);
525
- const motion = deriveMotionPreference(aggregatedMotion);
526
- const components = deriveComponentPreference(aggregatedComponents);
527
- const aggregateTokensFromProject = buildAggregateTokens(latestProject.inspirations);
528
- const aggregateTokensFromPreview = previewAnalyses
529
- .map(designAnalysisToScanTokens)
530
- .reduce((acc, tokens) => ({
531
- colors: unique([...acc.colors, ...tokens.colors]),
532
- fontFamilies: unique([...acc.fontFamilies, ...tokens.fontFamilies]),
533
- fontSizes: unique([...acc.fontSizes, ...tokens.fontSizes]),
534
- transitions: [...acc.transitions, ...tokens.transitions],
535
- spacingValues: [...acc.spacingValues, ...tokens.spacingValues],
536
- cssVariableCount: acc.cssVariableCount + tokens.cssVariableCount,
537
- framework: null,
538
- }), {
539
- colors: [],
540
- fontFamilies: [],
541
- fontSizes: [],
542
- transitions: [],
543
- spacingValues: [],
544
- cssVariableCount: 0,
545
- framework: null,
546
- });
547
- const aggregateTokens = {
548
- colors: unique([...aggregateTokensFromProject.colors, ...aggregateTokensFromPreview.colors]),
549
- fontFamilies: unique([...aggregateTokensFromProject.fontFamilies, ...aggregateTokensFromPreview.fontFamilies]),
550
- fontSizes: unique([...aggregateTokensFromProject.fontSizes, ...aggregateTokensFromPreview.fontSizes]),
551
- transitions: [...aggregateTokensFromProject.transitions, ...aggregateTokensFromPreview.transitions],
552
- spacingValues: [...aggregateTokensFromProject.spacingValues, ...aggregateTokensFromPreview.spacingValues],
553
- cssVariableCount: aggregateTokensFromProject.cssVariableCount + aggregateTokensFromPreview.cssVariableCount,
554
- framework: null,
342
+ // Step 4: Aggregate
343
+ const colors = aggregateColors(inspirations);
344
+ const typography = aggregateTypography(inspirations);
345
+ const components = aggregateComponents(inspirations);
346
+ const motion = aggregateMotion(inspirations);
347
+ // Step 5: Derive preferences
348
+ const colorPref = deriveColorPreference(colors, inspirations);
349
+ const typographyPref = deriveTypographyPreference(typography);
350
+ const spacingPref = deriveSpacingPreference(components);
351
+ const motionPref = deriveMotionPreference(motion);
352
+ const componentPref = deriveComponentPreference(components);
353
+ // Step 6: Score + persona
354
+ const mergedAnalysis = {
355
+ colors,
356
+ typography,
357
+ components: components.map(({ count, ...rest }) => rest),
358
+ motion: motion.map(({ count, ...rest }) => rest),
359
+ layout: inspirations.flatMap((i) => i.analysis.layout).slice(0, 50),
360
+ cssVariables: {},
555
361
  };
556
- const aggregateScore = computeScore(aggregateTokens);
557
- const persona = assignPersona(aggregateTokens);
558
- const detectedConflicts = detectConflicts(latestProject.inspirations);
559
- const previous = await loadTasteProfile(options.rootDir, project.id);
560
- const now = nowIso();
362
+ const tokens = designAnalysisToScanTokens(mergedAnalysis);
363
+ const score = computeScore(tokens);
364
+ const persona = assignPersona(score);
365
+ // Step 7: Detect conflicts
366
+ const conflicts = detectConflicts(inspirations);
367
+ // Step 8: LLM narrative
368
+ let narrative;
369
+ let principles;
370
+ if (llm) {
371
+ try {
372
+ const enrichment = await enrichWithLlm({
373
+ llm,
374
+ sourceName: `Taste profile for ${project.name}`,
375
+ analysis: mergedAnalysis,
376
+ });
377
+ narrative = enrichment.summary;
378
+ principles = enrichment.designPrinciples;
379
+ }
380
+ catch {
381
+ // LLM enrichment is optional
382
+ }
383
+ }
384
+ // Step 9: Build + save
385
+ const existing = await loadTasteProfile(rootDir, project.id);
386
+ const version = existing ? existing.version + 1 : 1;
561
387
  const profile = {
562
388
  id: project.id,
563
- name: options.projectName?.trim() || latestProject.name,
564
- version: (previous?.version ?? 0) + 1,
565
- sourceInspirationIds: latestProject.inspirations.map((inspiration) => inspiration.id),
566
- sourceUrls: unique(latestProject.inspirations.map((inspiration) => inspiration.url).filter((url) => Boolean(url))),
389
+ name: project.name,
390
+ version,
391
+ sourceInspirationIds: inspirations.map((i) => i.id),
392
+ sourceUrls: inspirations.filter((i) => i.url).map((i) => i.url),
567
393
  persona,
568
- aggregateScore,
569
- color,
570
- typography,
571
- spacing,
572
- motion,
573
- components: {
574
- ...components,
575
- cherryPicks: previous?.components.cherryPicks ?? [],
576
- },
577
- decisions: previous?.decisions ?? [],
578
- conflicts: mergeConflicts(previous, detectedConflicts),
579
- narrative: previous?.narrative,
580
- principles: previous?.principles,
581
- createdAt: previous?.createdAt ?? now,
582
- updatedAt: now,
394
+ aggregateScore: score,
395
+ color: colorPref,
396
+ typography: typographyPref,
397
+ spacing: spacingPref,
398
+ motion: motionPref,
399
+ components: componentPref,
400
+ decisions: existing?.decisions ?? [],
401
+ conflicts,
402
+ narrative,
403
+ principles,
404
+ createdAt: existing?.createdAt ?? nowIso(),
405
+ updatedAt: nowIso(),
583
406
  };
584
- try {
585
- const narrative = await maybeGenerateNarrative({
586
- llm: options.llm,
587
- profile,
588
- });
589
- profile.narrative = narrative.narrative ?? profile.narrative;
590
- profile.principles = narrative.principles ?? profile.principles;
591
- }
592
- catch {
593
- // LLM enrichment is optional for taste profile building.
594
- }
595
- await saveTasteProfile(options.rootDir, profile);
407
+ await saveTasteProfile(rootDir, profile);
408
+ // Step 10: Return
596
409
  return profile;
597
410
  }
598
411
  //# sourceMappingURL=taste.js.map