edsger 0.36.2 → 0.37.0

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 (127) hide show
  1. package/.claude/settings.local.json +23 -3
  2. package/.env.local +12 -0
  3. package/dist/api/features/__tests__/regression-prevention.test.d.ts +5 -0
  4. package/dist/api/features/__tests__/regression-prevention.test.js +338 -0
  5. package/dist/api/features/__tests__/status-updater.integration.test.d.ts +5 -0
  6. package/dist/api/features/__tests__/status-updater.integration.test.js +497 -0
  7. package/dist/api/growth.d.ts +23 -1
  8. package/dist/api/growth.js +25 -0
  9. package/dist/commands/app-store/index.js +2 -6
  10. package/dist/commands/code-review/index.js +3 -3
  11. package/dist/commands/growth-analysis/index.js +2 -0
  12. package/dist/commands/init/index.js +3 -3
  13. package/dist/commands/workflow/pipeline-runner.d.ts +17 -0
  14. package/dist/commands/workflow/pipeline-runner.js +393 -0
  15. package/dist/commands/workflow/runner.d.ts +26 -0
  16. package/dist/commands/workflow/runner.js +119 -0
  17. package/dist/commands/workflow/workflow-runner.d.ts +26 -0
  18. package/dist/commands/workflow/workflow-runner.js +119 -0
  19. package/dist/index.js +4 -0
  20. package/dist/phases/app-store-generation/__tests__/screenshot-composer.test.js +452 -32
  21. package/dist/phases/app-store-generation/assets/inter-latin-ext.woff2 +0 -0
  22. package/dist/phases/app-store-generation/assets/inter-latin.woff2 +0 -0
  23. package/dist/phases/app-store-generation/inter-font.d.ts +20 -0
  24. package/dist/phases/app-store-generation/inter-font.js +49 -0
  25. package/dist/phases/app-store-generation/screenshot-composer.js +183 -19
  26. package/dist/phases/code-implementation/analyzer-helpers.d.ts +28 -0
  27. package/dist/phases/code-implementation/analyzer-helpers.js +177 -0
  28. package/dist/phases/code-implementation/analyzer.d.ts +32 -0
  29. package/dist/phases/code-implementation/analyzer.js +629 -0
  30. package/dist/phases/code-implementation/context-fetcher.d.ts +17 -0
  31. package/dist/phases/code-implementation/context-fetcher.js +86 -0
  32. package/dist/phases/code-implementation/mcp-server.d.ts +1 -0
  33. package/dist/phases/code-implementation/mcp-server.js +93 -0
  34. package/dist/phases/code-implementation/prompts-improvement.d.ts +5 -0
  35. package/dist/phases/code-implementation/prompts-improvement.js +108 -0
  36. package/dist/phases/code-implementation-verification/verifier.d.ts +31 -0
  37. package/dist/phases/code-implementation-verification/verifier.js +196 -0
  38. package/dist/phases/code-refine/analyzer.d.ts +41 -0
  39. package/dist/phases/code-refine/analyzer.js +561 -0
  40. package/dist/phases/code-refine/context-fetcher.d.ts +94 -0
  41. package/dist/phases/code-refine/context-fetcher.js +423 -0
  42. package/dist/phases/code-refine-verification/analysis/llm-analyzer.d.ts +22 -0
  43. package/dist/phases/code-refine-verification/analysis/llm-analyzer.js +134 -0
  44. package/dist/phases/code-refine-verification/verifier.d.ts +47 -0
  45. package/dist/phases/code-refine-verification/verifier.js +597 -0
  46. package/dist/phases/code-review/analyzer.d.ts +29 -0
  47. package/dist/phases/code-review/analyzer.js +363 -0
  48. package/dist/phases/code-review/context-fetcher.d.ts +92 -0
  49. package/dist/phases/code-review/context-fetcher.js +296 -0
  50. package/dist/phases/feature-analysis/analyzer-helpers.d.ts +10 -0
  51. package/dist/phases/feature-analysis/analyzer-helpers.js +47 -0
  52. package/dist/phases/feature-analysis/analyzer.d.ts +11 -0
  53. package/dist/phases/feature-analysis/analyzer.js +208 -0
  54. package/dist/phases/feature-analysis/context-fetcher.d.ts +26 -0
  55. package/dist/phases/feature-analysis/context-fetcher.js +134 -0
  56. package/dist/phases/feature-analysis/http-fallback.d.ts +20 -0
  57. package/dist/phases/feature-analysis/http-fallback.js +95 -0
  58. package/dist/phases/feature-analysis/mcp-server.d.ts +1 -0
  59. package/dist/phases/feature-analysis/mcp-server.js +144 -0
  60. package/dist/phases/feature-analysis/prompts-improvement.d.ts +8 -0
  61. package/dist/phases/feature-analysis/prompts-improvement.js +109 -0
  62. package/dist/phases/feature-analysis-verification/verifier.d.ts +37 -0
  63. package/dist/phases/feature-analysis-verification/verifier.js +147 -0
  64. package/dist/phases/growth-analysis/context.d.ts +2 -2
  65. package/dist/phases/growth-analysis/context.js +18 -4
  66. package/dist/phases/growth-analysis/index.d.ts +2 -0
  67. package/dist/phases/growth-analysis/index.js +21 -13
  68. package/dist/phases/technical-design/analyzer-helpers.d.ts +25 -0
  69. package/dist/phases/technical-design/analyzer-helpers.js +39 -0
  70. package/dist/phases/technical-design/analyzer.d.ts +21 -0
  71. package/dist/phases/technical-design/analyzer.js +461 -0
  72. package/dist/phases/technical-design/context-fetcher.d.ts +12 -0
  73. package/dist/phases/technical-design/context-fetcher.js +39 -0
  74. package/dist/phases/technical-design/http-fallback.d.ts +17 -0
  75. package/dist/phases/technical-design/http-fallback.js +151 -0
  76. package/dist/phases/technical-design/mcp-server.d.ts +1 -0
  77. package/dist/phases/technical-design/mcp-server.js +157 -0
  78. package/dist/phases/technical-design/prompts-improvement.d.ts +5 -0
  79. package/dist/phases/technical-design/prompts-improvement.js +93 -0
  80. package/dist/phases/technical-design-verification/verifier.d.ts +53 -0
  81. package/dist/phases/technical-design-verification/verifier.js +170 -0
  82. package/dist/services/feature-branches.d.ts +77 -0
  83. package/dist/services/feature-branches.js +205 -0
  84. package/dist/services/video/device-frames.d.ts +1 -1
  85. package/dist/services/video/device-frames.js +81 -3
  86. package/dist/services/video/index.d.ts +1 -1
  87. package/dist/services/video/index.js +12 -6
  88. package/dist/services/video/screenshot-generator.js +5 -8
  89. package/dist/services/video/video-assembler.js +2 -6
  90. package/dist/types/index.d.ts +2 -0
  91. package/dist/utils/validation.d.ts +11 -2
  92. package/dist/utils/validation.js +93 -6
  93. package/dist/workflow-runner/config/phase-configs.d.ts +5 -0
  94. package/dist/workflow-runner/config/phase-configs.js +120 -0
  95. package/dist/workflow-runner/core/feature-filter.d.ts +16 -0
  96. package/dist/workflow-runner/core/feature-filter.js +46 -0
  97. package/dist/workflow-runner/core/index.d.ts +8 -0
  98. package/dist/workflow-runner/core/index.js +12 -0
  99. package/dist/workflow-runner/core/pipeline-evaluator.d.ts +24 -0
  100. package/dist/workflow-runner/core/pipeline-evaluator.js +32 -0
  101. package/dist/workflow-runner/core/state-manager.d.ts +24 -0
  102. package/dist/workflow-runner/core/state-manager.js +42 -0
  103. package/dist/workflow-runner/core/workflow-logger.d.ts +20 -0
  104. package/dist/workflow-runner/core/workflow-logger.js +65 -0
  105. package/dist/workflow-runner/executors/phase-executor.d.ts +8 -0
  106. package/dist/workflow-runner/executors/phase-executor.js +248 -0
  107. package/dist/workflow-runner/feature-workflow-runner.d.ts +26 -0
  108. package/dist/workflow-runner/feature-workflow-runner.js +119 -0
  109. package/dist/workflow-runner/index.d.ts +2 -0
  110. package/dist/workflow-runner/index.js +2 -0
  111. package/dist/workflow-runner/pipeline-runner.d.ts +17 -0
  112. package/dist/workflow-runner/pipeline-runner.js +393 -0
  113. package/dist/workflow-runner/workflow-processor.d.ts +54 -0
  114. package/dist/workflow-runner/workflow-processor.js +170 -0
  115. package/package.json +2 -2
  116. package/dist/services/lifecycle-agent/__tests__/phase-criteria.test.d.ts +0 -4
  117. package/dist/services/lifecycle-agent/__tests__/phase-criteria.test.js +0 -133
  118. package/dist/services/lifecycle-agent/__tests__/transition-rules.test.d.ts +0 -4
  119. package/dist/services/lifecycle-agent/__tests__/transition-rules.test.js +0 -336
  120. package/dist/services/lifecycle-agent/index.d.ts +0 -24
  121. package/dist/services/lifecycle-agent/index.js +0 -25
  122. package/dist/services/lifecycle-agent/phase-criteria.d.ts +0 -57
  123. package/dist/services/lifecycle-agent/phase-criteria.js +0 -335
  124. package/dist/services/lifecycle-agent/transition-rules.d.ts +0 -60
  125. package/dist/services/lifecycle-agent/transition-rules.js +0 -184
  126. package/dist/services/lifecycle-agent/types.d.ts +0 -190
  127. package/dist/services/lifecycle-agent/types.js +0 -12
package/dist/index.js CHANGED
@@ -119,11 +119,15 @@ program
119
119
  .command('growth <productId>')
120
120
  .description('Run AI-powered growth analysis for a product')
121
121
  .option('-v, --verbose', 'Verbose output')
122
+ .option('-g, --guidance <text>', 'Human direction for the AI (themes, audience, tone, etc.)')
123
+ .option('--analysis-id <id>', 'Update an existing pending analysis instead of creating new')
122
124
  .action(async (productId, opts) => {
123
125
  try {
124
126
  await runGrowthAnalysis({
125
127
  growthAnalysis: productId,
126
128
  verbose: opts.verbose,
129
+ growthGuidance: opts.guidance,
130
+ growthAnalysisId: opts.analysisId,
127
131
  });
128
132
  }
129
133
  catch (error) {
@@ -15,8 +15,9 @@ function escapeHtml(str) {
15
15
  function mapDeviceFrame(frame) {
16
16
  switch (frame.toLowerCase()) {
17
17
  case 'iphone':
18
- case 'ipad':
19
18
  return 'iphone';
19
+ case 'ipad':
20
+ return 'ipad';
20
21
  case 'macbook':
21
22
  return 'macbook';
22
23
  case 'browser':
@@ -25,13 +26,105 @@ function mapDeviceFrame(frame) {
25
26
  return 'iphone';
26
27
  }
27
28
  }
29
+ const NAMED_COLORS = {
30
+ white: [255, 255, 255],
31
+ black: [0, 0, 0],
32
+ red: [255, 0, 0],
33
+ green: [0, 128, 0],
34
+ blue: [0, 0, 255],
35
+ yellow: [255, 255, 0],
36
+ cyan: [0, 255, 255],
37
+ magenta: [255, 0, 255],
38
+ gray: [128, 128, 128],
39
+ grey: [128, 128, 128],
40
+ orange: [255, 165, 0],
41
+ purple: [128, 0, 128],
42
+ pink: [255, 192, 203],
43
+ };
44
+ function parseColor(color) {
45
+ const s = color.trim().toLowerCase();
46
+ if (NAMED_COLORS[s]) {
47
+ return NAMED_COLORS[s];
48
+ }
49
+ const rgbMatch = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec(s);
50
+ if (rgbMatch) {
51
+ return [parseInt(rgbMatch[1]), parseInt(rgbMatch[2]), parseInt(rgbMatch[3])];
52
+ }
53
+ const hexMatch = /^#?([0-9a-f]{3,8})$/i.exec(s);
54
+ if (!hexMatch) {
55
+ return null;
56
+ }
57
+ const h = hexMatch[1];
58
+ if (h.length === 3) {
59
+ return [
60
+ parseInt(h[0] + h[0], 16),
61
+ parseInt(h[1] + h[1], 16),
62
+ parseInt(h[2] + h[2], 16),
63
+ ];
64
+ }
65
+ if (h.length >= 6) {
66
+ return [
67
+ parseInt(h.slice(0, 2), 16),
68
+ parseInt(h.slice(2, 4), 16),
69
+ parseInt(h.slice(4, 6), 16),
70
+ ];
71
+ }
72
+ return null;
73
+ }
74
+ function colorLuminance(color) {
75
+ const rgb = parseColor(color);
76
+ if (!rgb) {
77
+ return 1.0;
78
+ }
79
+ const toLinear = (c) => {
80
+ const s = c / 255;
81
+ return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
82
+ };
83
+ return (0.2126 * toLinear(rgb[0]) +
84
+ 0.7152 * toLinear(rgb[1]) +
85
+ 0.0722 * toLinear(rgb[2]));
86
+ }
87
+ function smoothstep(edge0, edge1, x) {
88
+ const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
89
+ return t * t * (3 - 2 * t);
90
+ }
91
+ function contrastStyles(textColor) {
92
+ const lum = colorLuminance(textColor);
93
+ const lightness = smoothstep(0.15, 0.65, lum);
94
+ const ch = Math.round(255 * (1 - lightness));
95
+ const certainty = Math.abs(lightness - 0.5) * 2;
96
+ const baseOverlay = 0.25 * lightness + 0.2 * (1 - lightness);
97
+ const baseShadow = 0.35 * lightness + 0.4 * (1 - lightness);
98
+ const baseShadowLight = 0.25 * lightness + 0.3 * (1 - lightness);
99
+ const scale = 0.5 + 0.5 * certainty;
100
+ const fmt = (alpha) => (alpha * scale).toFixed(2);
101
+ return {
102
+ overlayColor: `rgba(${ch},${ch},${ch},${fmt(baseOverlay)})`,
103
+ shadowColor: `rgba(${ch},${ch},${ch},${fmt(baseShadow)})`,
104
+ shadowColorLight: `rgba(${ch},${ch},${ch},${fmt(baseShadowLight)})`,
105
+ };
106
+ }
107
+ // Simulates getInterFontStyle() output for testing (without real base64 font data)
108
+ const testFontStyle = `<style>
109
+ @font-face {
110
+ font-family: 'Inter';
111
+ font-style: normal;
112
+ font-weight: 100 900;
113
+ font-display: swap;
114
+ src: url(data:font/woff2;base64,TEST) format('woff2');
115
+ }
116
+ </style>`;
28
117
  function createCompositionHtml(framedScreenshotDataUrl, spec, targetWidth, targetHeight) {
29
118
  const isPortrait = targetHeight > targetWidth;
30
119
  const textColor = spec.text_color || '#ffffff';
120
+ const fontStyle = testFontStyle;
121
+ const fontFamily = `'Inter', -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif`;
122
+ const { overlayColor, shadowColor, shadowColorLight } = contrastStyles(textColor);
31
123
  if (isPortrait) {
32
124
  return `<!DOCTYPE html>
33
125
  <html>
34
126
  <head>
127
+ ${fontStyle}
35
128
  <style>
36
129
  * { margin: 0; padding: 0; box-sizing: border-box; }
37
130
  body {
@@ -42,41 +135,64 @@ function createCompositionHtml(framedScreenshotDataUrl, spec, targetWidth, targe
42
135
  flex-direction: column;
43
136
  align-items: center;
44
137
  justify-content: flex-start;
45
- font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
138
+ font-family: ${fontFamily};
46
139
  overflow: hidden;
47
140
  }
48
141
  .text-area {
49
- padding: ${Math.round(targetHeight * 0.06)}px ${Math.round(targetWidth * 0.08)}px;
142
+ padding: ${Math.round(targetHeight * 0.05)}px ${Math.round(targetWidth * 0.08)}px;
50
143
  text-align: center;
51
144
  flex-shrink: 0;
145
+ max-height: 28%;
146
+ position: relative;
147
+ }
148
+ .text-area::after {
149
+ content: '';
150
+ position: absolute;
151
+ inset: 0;
152
+ background: linear-gradient(to bottom, ${overlayColor} 0%, transparent 100%);
153
+ border-radius: inherit;
154
+ pointer-events: none;
52
155
  }
53
156
  .headline {
54
- font-size: ${Math.round(targetWidth * 0.07)}px;
157
+ font-size: ${Math.round(targetWidth * 0.1)}px;
55
158
  font-weight: 800;
56
159
  color: ${textColor};
57
160
  line-height: 1.15;
58
161
  letter-spacing: -0.02em;
59
162
  margin-bottom: ${Math.round(targetHeight * 0.01)}px;
163
+ text-shadow: 0 2px 12px ${shadowColor};
164
+ position: relative;
165
+ z-index: 1;
166
+ display: -webkit-box;
167
+ -webkit-line-clamp: 3;
168
+ -webkit-box-orient: vertical;
169
+ overflow: hidden;
60
170
  }
61
171
  .subheadline {
62
- font-size: ${Math.round(targetWidth * 0.035)}px;
172
+ font-size: ${Math.round(targetWidth * 0.05)}px;
63
173
  font-weight: 400;
64
174
  color: ${textColor};
65
175
  opacity: 0.85;
66
176
  line-height: 1.3;
177
+ text-shadow: 0 1px 8px ${shadowColorLight};
178
+ position: relative;
179
+ z-index: 1;
180
+ display: -webkit-box;
181
+ -webkit-line-clamp: 2;
182
+ -webkit-box-orient: vertical;
183
+ overflow: hidden;
67
184
  }
68
185
  .device-area {
69
186
  flex: 1;
70
187
  display: flex;
71
- align-items: flex-start;
188
+ align-items: center;
72
189
  justify-content: center;
73
190
  overflow: hidden;
74
- padding: 0 ${Math.round(targetWidth * 0.05)}px;
191
+ min-height: 0;
192
+ padding: 0 ${Math.round(targetWidth * 0.02)}px;
75
193
  }
76
194
  .device-area img {
77
- max-width: 100%;
78
- max-height: 100%;
79
- object-fit: contain;
195
+ width: 92%;
80
196
  }
81
197
  </style>
82
198
  </head>
@@ -94,6 +210,7 @@ function createCompositionHtml(framedScreenshotDataUrl, spec, targetWidth, targe
94
210
  return `<!DOCTYPE html>
95
211
  <html>
96
212
  <head>
213
+ ${fontStyle}
97
214
  <style>
98
215
  * { margin: 0; padding: 0; box-sizing: border-box; }
99
216
  body {
@@ -102,41 +219,70 @@ function createCompositionHtml(framedScreenshotDataUrl, spec, targetWidth, targe
102
219
  background: ${spec.background_gradient};
103
220
  display: flex;
104
221
  align-items: center;
105
- font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
222
+ font-family: ${fontFamily};
106
223
  overflow: hidden;
107
224
  }
108
225
  .text-area {
109
- width: 40%;
226
+ flex: 0 1 auto;
227
+ min-width: 28%;
228
+ max-width: 44%;
110
229
  padding: 0 ${Math.round(targetWidth * 0.04)}px;
230
+ display: flex;
231
+ flex-direction: column;
232
+ justify-content: center;
111
233
  text-align: left;
234
+ position: relative;
235
+ }
236
+ .text-area::after {
237
+ content: '';
238
+ position: absolute;
239
+ inset: 0;
240
+ background: linear-gradient(to right, ${overlayColor} 0%, transparent 100%);
241
+ border-radius: inherit;
242
+ pointer-events: none;
112
243
  }
113
244
  .headline {
114
- font-size: ${Math.round(targetWidth * 0.04)}px;
245
+ font-size: ${Math.round(targetWidth * 0.055)}px;
115
246
  font-weight: 800;
116
247
  color: ${textColor};
117
248
  line-height: 1.15;
118
249
  letter-spacing: -0.02em;
119
250
  margin-bottom: ${Math.round(targetHeight * 0.02)}px;
251
+ text-shadow: 0 2px 12px ${shadowColor};
252
+ position: relative;
253
+ z-index: 1;
254
+ display: -webkit-box;
255
+ -webkit-line-clamp: 3;
256
+ -webkit-box-orient: vertical;
257
+ overflow: hidden;
120
258
  }
121
259
  .subheadline {
122
- font-size: ${Math.round(targetWidth * 0.02)}px;
260
+ font-size: ${Math.round(targetWidth * 0.028)}px;
123
261
  font-weight: 400;
124
262
  color: ${textColor};
125
263
  opacity: 0.85;
126
264
  line-height: 1.4;
265
+ text-shadow: 0 1px 8px ${shadowColorLight};
266
+ position: relative;
267
+ z-index: 1;
268
+ display: -webkit-box;
269
+ -webkit-line-clamp: 2;
270
+ -webkit-box-orient: vertical;
271
+ overflow: hidden;
127
272
  }
128
273
  .device-area {
129
- width: 60%;
274
+ flex: 1 1 0;
275
+ min-width: 0;
130
276
  height: 100%;
131
277
  display: flex;
132
278
  align-items: center;
133
279
  justify-content: center;
134
280
  overflow: hidden;
135
- padding: ${Math.round(targetHeight * 0.05)}px;
281
+ padding: ${Math.round(targetHeight * 0.03)}px;
136
282
  }
137
283
  .device-area img {
138
284
  max-width: 100%;
139
- max-height: 100%;
285
+ max-height: 95%;
140
286
  object-fit: contain;
141
287
  }
142
288
  </style>
@@ -153,6 +299,132 @@ function createCompositionHtml(framedScreenshotDataUrl, spec, targetWidth, targe
153
299
  </html>`;
154
300
  }
155
301
  // ============================================================
302
+ // colorLuminance + parseColor tests
303
+ // ============================================================
304
+ void describe('colorLuminance', () => {
305
+ void it('should return ~1.0 for white (#ffffff)', () => {
306
+ const lum = colorLuminance('#ffffff');
307
+ assert.ok(lum > 0.99, `White luminance should be ~1.0, got ${lum}`);
308
+ });
309
+ void it('should return ~0.0 for black (#000000)', () => {
310
+ const lum = colorLuminance('#000000');
311
+ assert.ok(lum < 0.01, `Black luminance should be ~0.0, got ${lum}`);
312
+ });
313
+ void it('should handle 3-digit hex (#fff)', () => {
314
+ const lum = colorLuminance('#fff');
315
+ assert.ok(lum > 0.99, `#fff luminance should be ~1.0, got ${lum}`);
316
+ });
317
+ void it('should handle hex without # prefix', () => {
318
+ const lum = colorLuminance('ff0000');
319
+ assert.ok(lum > 0.1 && lum < 0.3, `Red luminance should be ~0.21, got ${lum}`);
320
+ });
321
+ void it('should return 1.0 for invalid input (safe default)', () => {
322
+ assert.strictEqual(colorLuminance('notacolor'), 1.0);
323
+ assert.strictEqual(colorLuminance(''), 1.0);
324
+ });
325
+ void it('should calculate mid-range luminance for gray (#808080)', () => {
326
+ const lum = colorLuminance('#808080');
327
+ assert.ok(lum > 0.15 && lum < 0.25, `Gray luminance should be ~0.22, got ${lum}`);
328
+ });
329
+ void it('should parse rgb() format', () => {
330
+ const lum = colorLuminance('rgb(255, 0, 0)');
331
+ assert.ok(lum > 0.1 && lum < 0.3, `rgb red luminance should be ~0.21, got ${lum}`);
332
+ });
333
+ void it('should parse rgba() format', () => {
334
+ const lum = colorLuminance('rgba(255, 255, 255, 0.5)');
335
+ assert.ok(lum > 0.99, `rgba white luminance should be ~1.0, got ${lum}`);
336
+ });
337
+ void it('should parse named colors', () => {
338
+ assert.ok(colorLuminance('white') > 0.99, 'Named "white" should be ~1.0');
339
+ assert.ok(colorLuminance('black') < 0.01, 'Named "black" should be ~0.0');
340
+ assert.ok(colorLuminance('Red') > 0.1, 'Named "Red" (case insensitive) should parse');
341
+ });
342
+ });
343
+ // ============================================================
344
+ // contrastStyles tests
345
+ // ============================================================
346
+ void describe('contrastStyles', () => {
347
+ void it('should return dark overlay for white text', () => {
348
+ const styles = contrastStyles('#ffffff');
349
+ assert.strictEqual(styles.overlayColor, 'rgba(0,0,0,0.25)');
350
+ assert.strictEqual(styles.shadowColor, 'rgba(0,0,0,0.35)');
351
+ });
352
+ void it('should return light overlay for black text', () => {
353
+ const styles = contrastStyles('#000000');
354
+ assert.strictEqual(styles.overlayColor, 'rgba(255,255,255,0.20)');
355
+ assert.strictEqual(styles.shadowColor, 'rgba(255,255,255,0.40)');
356
+ });
357
+ void it('should return dark overlay for light colors like #f0f0f0', () => {
358
+ const styles = contrastStyles('#f0f0f0');
359
+ assert.ok(styles.overlayColor.startsWith('rgba(0,0,0,'), 'Light gray text should get dark overlay');
360
+ });
361
+ void it('should return light overlay for dark colors like #333333', () => {
362
+ const styles = contrastStyles('#333333');
363
+ assert.ok(styles.overlayColor.startsWith('rgba(255,255,255,'), 'Dark gray text should get light overlay');
364
+ });
365
+ void it('should reduce overlay strength for mid-range text', () => {
366
+ // #aaaaaa has luminance ~0.40, right in the smoothstep transition zone
367
+ const midStyles = contrastStyles('#aaaaaa');
368
+ const midAlpha = parseFloat(midStyles.overlayColor.split(',')[3]);
369
+ const extremeAlpha = parseFloat(contrastStyles('#ffffff').overlayColor.split(',')[3]);
370
+ assert.ok(midAlpha < extremeAlpha, `Mid-range alpha ${midAlpha} should be less than extreme alpha ${extremeAlpha}`);
371
+ });
372
+ void it('should produce smooth channel gradient for mid-range text', () => {
373
+ // Mid-range text should produce a gray channel, not pure black or white
374
+ const styles = contrastStyles('#aaaaaa');
375
+ const ch = parseInt(styles.overlayColor.split('(')[1]);
376
+ assert.ok(ch > 10 && ch < 245, `Mid-range text should produce gray channel, got ${ch}`);
377
+ });
378
+ void it('should default to dark overlay for unparseable color', () => {
379
+ const styles = contrastStyles('invalid');
380
+ assert.strictEqual(styles.overlayColor, 'rgba(0,0,0,0.25)');
381
+ });
382
+ void it('should work with rgb() input', () => {
383
+ const styles = contrastStyles('rgb(255, 255, 255)');
384
+ assert.strictEqual(styles.overlayColor, 'rgba(0,0,0,0.25)');
385
+ });
386
+ void it('should work with named color input', () => {
387
+ const styles = contrastStyles('black');
388
+ assert.strictEqual(styles.overlayColor, 'rgba(255,255,255,0.20)');
389
+ });
390
+ });
391
+ // ============================================================
392
+ // createCompositionHtml - Adaptive contrast
393
+ // ============================================================
394
+ void describe('createCompositionHtml - Adaptive contrast', () => {
395
+ const dataUrl = 'data:image/png;base64,x';
396
+ const baseSpec = {
397
+ feature_highlight: 'Test',
398
+ headline: 'Test',
399
+ subheadline: 'Sub',
400
+ background_gradient: 'linear-gradient(#000, #111)',
401
+ text_color: '#ffffff',
402
+ device_frame: 'iphone',
403
+ html_template: '<html></html>',
404
+ };
405
+ void it('should use dark overlay/shadow for white text (#ffffff)', () => {
406
+ const html = createCompositionHtml(dataUrl, baseSpec, 1290, 2796);
407
+ assert.ok(html.includes('rgba(0,0,0,0.25)'), 'White text should produce dark overlay');
408
+ assert.ok(html.includes('rgba(0,0,0,0.35)'), 'White text should produce dark text-shadow');
409
+ });
410
+ void it('should use light overlay/shadow for dark text (#1a1a1a)', () => {
411
+ const darkTextSpec = { ...baseSpec, text_color: '#1a1a1a' };
412
+ const html = createCompositionHtml(dataUrl, darkTextSpec, 1290, 2796);
413
+ assert.ok(html.includes('rgba(255,255,255,0.20)'), 'Dark text should produce light overlay');
414
+ assert.ok(html.includes('rgba(255,255,255,0.40)'), 'Dark text should produce light text-shadow');
415
+ });
416
+ void it('should use light overlay for landscape with dark text', () => {
417
+ const darkTextSpec = { ...baseSpec, text_color: '#000000' };
418
+ const html = createCompositionHtml(dataUrl, darkTextSpec, 1920, 1200);
419
+ assert.ok(html.includes('rgba(255,255,255,0.20)'), 'Landscape dark text should produce light overlay');
420
+ });
421
+ void it('should default to dark overlay when text_color is empty', () => {
422
+ const emptySpec = { ...baseSpec, text_color: '' };
423
+ const html = createCompositionHtml(dataUrl, emptySpec, 1290, 2796);
424
+ assert.ok(html.includes('rgba(0,0,0,0.25)'), 'Empty text_color defaults to #ffffff → dark overlay');
425
+ });
426
+ });
427
+ // ============================================================
156
428
  // escapeHtml tests
157
429
  // ============================================================
158
430
  void describe('escapeHtml', () => {
@@ -188,8 +460,8 @@ void describe('mapDeviceFrame', () => {
188
460
  void it('should map "iphone" to "iphone"', () => {
189
461
  assert.strictEqual(mapDeviceFrame('iphone'), 'iphone');
190
462
  });
191
- void it('should map "ipad" to "iphone"', () => {
192
- assert.strictEqual(mapDeviceFrame('ipad'), 'iphone');
463
+ void it('should map "ipad" to "ipad"', () => {
464
+ assert.strictEqual(mapDeviceFrame('ipad'), 'ipad');
193
465
  });
194
466
  void it('should map "macbook" to "macbook"', () => {
195
467
  assert.strictEqual(mapDeviceFrame('macbook'), 'macbook');
@@ -207,7 +479,7 @@ void describe('mapDeviceFrame', () => {
207
479
  assert.strictEqual(mapDeviceFrame('iPhone'), 'iphone');
208
480
  });
209
481
  void it('should be case insensitive for "iPad"', () => {
210
- assert.strictEqual(mapDeviceFrame('iPad'), 'iphone');
482
+ assert.strictEqual(mapDeviceFrame('iPad'), 'ipad');
211
483
  });
212
484
  void it('should be case insensitive for "MacBook"', () => {
213
485
  assert.strictEqual(mapDeviceFrame('MacBook'), 'macbook');
@@ -269,10 +541,10 @@ void describe('createCompositionHtml - Portrait mode', () => {
269
541
  assert.ok(html.includes(`width: ${width}px`), 'Should set target width in body style');
270
542
  assert.ok(html.includes(`height: ${height}px`), 'Should set target height in body style');
271
543
  });
272
- void it('should calculate headline font size as Math.round(width * 0.07)', () => {
544
+ void it('should calculate headline font size as Math.round(width * 0.10)', () => {
273
545
  const html = createCompositionHtml(dataUrl, spec, width, height);
274
- const expectedFontSize = Math.round(width * 0.07);
275
- assert.ok(html.includes(`font-size: ${expectedFontSize}px`), `Headline font size should be ${expectedFontSize}px (width * 0.07)`);
546
+ const expectedFontSize = Math.round(width * 0.1);
547
+ assert.ok(html.includes(`font-size: ${expectedFontSize}px`), `Headline font size should be ${expectedFontSize}px (width * 0.10)`);
276
548
  });
277
549
  void it('should use the specified text color', () => {
278
550
  const html = createCompositionHtml(dataUrl, spec, width, height);
@@ -306,22 +578,23 @@ void describe('createCompositionHtml - Landscape mode', () => {
306
578
  // Landscape: width > height
307
579
  const width = 1920;
308
580
  const height = 1200;
309
- void it('should contain text-area at 40% width', () => {
581
+ void it('should use adaptive text-area width with min/max bounds', () => {
310
582
  const html = createCompositionHtml(dataUrl, spec, width, height);
311
- assert.ok(html.includes('width: 40%'), 'Landscape text-area should be 40% width');
583
+ assert.ok(html.includes('min-width: 28%'), 'Landscape text-area should have min-width: 28%');
584
+ assert.ok(html.includes('max-width: 44%'), 'Landscape text-area should have max-width: 44%');
312
585
  });
313
- void it('should contain device-area at 60% width', () => {
586
+ void it('should use flexible device-area that fills remaining space', () => {
314
587
  const html = createCompositionHtml(dataUrl, spec, width, height);
315
- assert.ok(html.includes('width: 60%'), 'Landscape device-area should be 60% width');
588
+ assert.ok(html.includes('flex: 1 1 0'), 'Landscape device-area should flex to fill remaining space');
316
589
  });
317
- void it('should NOT contain flex-direction: column for landscape', () => {
590
+ void it('should NOT use portrait body layout for landscape', () => {
318
591
  const html = createCompositionHtml(dataUrl, spec, width, height);
319
- assert.ok(!html.includes('flex-direction: column'), 'Landscape mode should NOT use flex-direction: column');
592
+ assert.ok(!html.includes('justify-content: flex-start'), 'Landscape body should not use portrait-style vertical layout');
320
593
  });
321
- void it('should calculate headline font size as Math.round(width * 0.04)', () => {
594
+ void it('should calculate headline font size as Math.round(width * 0.055)', () => {
322
595
  const html = createCompositionHtml(dataUrl, spec, width, height);
323
- const expectedFontSize = Math.round(width * 0.04);
324
- assert.ok(html.includes(`font-size: ${expectedFontSize}px`), `Landscape headline font size should be ${expectedFontSize}px (width * 0.04)`);
596
+ const expectedFontSize = Math.round(width * 0.055);
597
+ assert.ok(html.includes(`font-size: ${expectedFontSize}px`), `Landscape headline font size should be ${expectedFontSize}px (width * 0.055)`);
325
598
  });
326
599
  void it('should contain the headline and subheadline text', () => {
327
600
  const html = createCompositionHtml(dataUrl, spec, width, height);
@@ -338,6 +611,153 @@ void describe('createCompositionHtml - Landscape mode', () => {
338
611
  });
339
612
  });
340
613
  // ============================================================
614
+ // createCompositionHtml - Font embedding
615
+ // ============================================================
616
+ void describe('createCompositionHtml - Font embedding', () => {
617
+ const spec = {
618
+ feature_highlight: 'Test',
619
+ headline: 'Test Headline',
620
+ subheadline: undefined,
621
+ background_gradient: 'linear-gradient(#000, #111)',
622
+ text_color: '#fff',
623
+ device_frame: 'iphone',
624
+ html_template: '<html></html>',
625
+ };
626
+ const dataUrl = 'data:image/png;base64,x';
627
+ void it('should embed @font-face declaration for Inter', () => {
628
+ const html = createCompositionHtml(dataUrl, spec, 1290, 2796);
629
+ assert.ok(html.includes("font-family: 'Inter'"), 'Should declare Inter in @font-face');
630
+ assert.ok(html.includes('font/woff2;base64,'), 'Should embed font as base64 WOFF2 data URI');
631
+ });
632
+ void it('should use Inter as primary font-family on body', () => {
633
+ const html = createCompositionHtml(dataUrl, spec, 1290, 2796);
634
+ assert.ok(html.includes("font-family: 'Inter', -apple-system"), 'Body font-family should start with Inter');
635
+ });
636
+ void it('should not reference external font CDN', () => {
637
+ const html = createCompositionHtml(dataUrl, spec, 1290, 2796);
638
+ assert.ok(!html.includes('fonts.googleapis.com'), 'Should not reference Google Fonts CDN');
639
+ });
640
+ });
641
+ // ============================================================
642
+ // createCompositionHtml - Text readability protection
643
+ // ============================================================
644
+ void describe('createCompositionHtml - Text readability protection', () => {
645
+ const spec = {
646
+ feature_highlight: 'Test',
647
+ headline: 'Test Headline',
648
+ subheadline: 'Test Sub',
649
+ background_gradient: 'linear-gradient(#fff, #eee)',
650
+ text_color: '#ffffff',
651
+ device_frame: 'iphone',
652
+ html_template: '<html></html>',
653
+ };
654
+ const dataUrl = 'data:image/png;base64,x';
655
+ void it('should apply text-shadow on headline for portrait', () => {
656
+ const html = createCompositionHtml(dataUrl, spec, 1290, 2796);
657
+ assert.ok(html.includes('text-shadow: 0 2px 12px rgba(0,0,0,0.35)'), 'Headline should have text-shadow for readability');
658
+ });
659
+ void it('should apply text-shadow on subheadline for portrait', () => {
660
+ const html = createCompositionHtml(dataUrl, spec, 1290, 2796);
661
+ assert.ok(html.includes('text-shadow: 0 1px 8px rgba(0,0,0,0.25)'), 'Subheadline should have lighter text-shadow');
662
+ });
663
+ void it('should apply text-shadow on headline for landscape', () => {
664
+ const html = createCompositionHtml(dataUrl, spec, 1920, 1200);
665
+ assert.ok(html.includes('text-shadow: 0 2px 12px rgba(0,0,0,0.35)'), 'Landscape headline should have text-shadow');
666
+ });
667
+ void it('should include gradient overlay on text-area for portrait', () => {
668
+ const html = createCompositionHtml(dataUrl, spec, 1290, 2796);
669
+ assert.ok(html.includes('.text-area::after'), 'Portrait should have ::after pseudo-element on text-area');
670
+ assert.ok(html.includes('linear-gradient(to bottom, rgba(0,0,0,0.25)'), 'Portrait overlay should gradient top-to-bottom');
671
+ });
672
+ void it('should include gradient overlay on text-area for landscape', () => {
673
+ const html = createCompositionHtml(dataUrl, spec, 1920, 1200);
674
+ assert.ok(html.includes('.text-area::after'), 'Landscape should have ::after pseudo-element on text-area');
675
+ assert.ok(html.includes('linear-gradient(to right, rgba(0,0,0,0.25)'), 'Landscape overlay should gradient left-to-right');
676
+ });
677
+ void it('should set z-index on headline/subheadline above overlay', () => {
678
+ const html = createCompositionHtml(dataUrl, spec, 1290, 2796);
679
+ assert.ok(html.includes('z-index: 1'), 'Text elements should have z-index above the overlay');
680
+ });
681
+ });
682
+ // ============================================================
683
+ // createCompositionHtml - Line clamping
684
+ // ============================================================
685
+ void describe('createCompositionHtml - Line clamping', () => {
686
+ const spec = {
687
+ feature_highlight: 'Test',
688
+ headline: 'Very Long Headline That Could Potentially Overflow',
689
+ subheadline: 'A subheadline that is also quite long and may overflow',
690
+ background_gradient: 'linear-gradient(#000, #111)',
691
+ text_color: '#fff',
692
+ device_frame: 'iphone',
693
+ html_template: '<html></html>',
694
+ };
695
+ const dataUrl = 'data:image/png;base64,x';
696
+ void it('should clamp headline to 3 lines in portrait', () => {
697
+ const html = createCompositionHtml(dataUrl, spec, 1290, 2796);
698
+ assert.ok(html.includes('-webkit-line-clamp: 3'), 'Portrait headline should be clamped to 3 lines');
699
+ });
700
+ void it('should clamp subheadline to 2 lines in portrait', () => {
701
+ const html = createCompositionHtml(dataUrl, spec, 1290, 2796);
702
+ assert.ok(html.includes('-webkit-line-clamp: 2'), 'Portrait subheadline should be clamped to 2 lines');
703
+ });
704
+ void it('should clamp headline to 3 lines in landscape', () => {
705
+ const html = createCompositionHtml(dataUrl, spec, 1920, 1200);
706
+ assert.ok(html.includes('-webkit-line-clamp: 3'), 'Landscape headline should be clamped to 3 lines');
707
+ });
708
+ void it('should use -webkit-box for line clamping', () => {
709
+ const html = createCompositionHtml(dataUrl, spec, 1290, 2796);
710
+ assert.ok(html.includes('display: -webkit-box'), 'Should use -webkit-box display for line clamping');
711
+ assert.ok(html.includes('-webkit-box-orient: vertical'), 'Should set -webkit-box-orient: vertical');
712
+ });
713
+ });
714
+ // ============================================================
715
+ // createCompositionHtml - Portrait layout details
716
+ // ============================================================
717
+ void describe('createCompositionHtml - Portrait layout details', () => {
718
+ const spec = {
719
+ feature_highlight: 'Test',
720
+ headline: 'Test',
721
+ subheadline: undefined,
722
+ background_gradient: 'linear-gradient(#000, #111)',
723
+ text_color: '#fff',
724
+ device_frame: 'iphone',
725
+ html_template: '<html></html>',
726
+ };
727
+ const dataUrl = 'data:image/png;base64,x';
728
+ void it('should limit text-area max-height to 28%', () => {
729
+ const html = createCompositionHtml(dataUrl, spec, 1290, 2796);
730
+ assert.ok(html.includes('max-height: 28%'), 'Portrait text-area should have max-height: 28%');
731
+ });
732
+ void it('should center device vertically in device-area', () => {
733
+ const html = createCompositionHtml(dataUrl, spec, 1290, 2796);
734
+ assert.ok(html.includes('align-items: center') && html.includes('min-height: 0'), 'Device-area should center content vertically with min-height: 0');
735
+ });
736
+ });
737
+ // ============================================================
738
+ // createCompositionHtml - Landscape layout details
739
+ // ============================================================
740
+ void describe('createCompositionHtml - Landscape layout details', () => {
741
+ const spec = {
742
+ feature_highlight: 'Test',
743
+ headline: 'Test',
744
+ subheadline: undefined,
745
+ background_gradient: 'linear-gradient(#000, #111)',
746
+ text_color: '#fff',
747
+ device_frame: 'browser',
748
+ html_template: '<html></html>',
749
+ };
750
+ const dataUrl = 'data:image/png;base64,x';
751
+ void it('should vertically center text inside text-area', () => {
752
+ const html = createCompositionHtml(dataUrl, spec, 1920, 1200);
753
+ assert.ok(html.includes('justify-content: center'), 'Text-area should vertically center its content');
754
+ });
755
+ void it('should prevent device-area overflow with min-width: 0', () => {
756
+ const html = createCompositionHtml(dataUrl, spec, 1920, 1200);
757
+ assert.ok(html.includes('min-width: 0'), 'Device-area should have min-width: 0 to prevent flex overflow');
758
+ });
759
+ });
760
+ // ============================================================
341
761
  // createCompositionHtml - HTML escaping
342
762
  // ============================================================
343
763
  void describe('createCompositionHtml - HTML escaping', () => {
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Inter font (variable weight) embedded as base64 WOFF2.
3
+ * Latin + Latin-ext subsets cover Western European languages.
4
+ * Font files are loaded from assets/ at runtime and cached in memory.
5
+ *
6
+ * Source: Google Fonts Inter v20, weights 100-900 (variable)
7
+ * Total size: ~130KB raw WOFF2 (Latin 47KB + Latin-ext 83KB)
8
+ *
9
+ * CJK limitation: Inter does not include Chinese, Japanese, or Korean
10
+ * glyphs. Text in these scripts falls back to the system font stack
11
+ * (-apple-system, BlinkMacSystemFont, etc.). To add CJK coverage,
12
+ * embed Noto Sans CJK subsets (~300-500KB per script), which is not
13
+ * included here to keep the composition payload reasonable.
14
+ */
15
+ /**
16
+ * Returns a <style> block with @font-face declarations for Inter.
17
+ * Embed this in <head> to load Inter without any network requests.
18
+ * Font data is read from disk on first call and cached thereafter.
19
+ */
20
+ export declare function getInterFontStyle(): string;