chaincss 2.2.0 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,415 @@
1
+ // src/compiler/responsive-inference.ts
2
+ /**
3
+ * Automatic Responsive Inference Engine
4
+ *
5
+ * Detects layout patterns that will break on mobile and suggests fixes.
6
+ * Positions ChainCSS as a layout advisor, not just a compiler.
7
+ *
8
+ * Detection rules:
9
+ * - Fixed widths > 768px → suggest min(100%, width)
10
+ * - Grid columns > 2 → suggest auto-fit
11
+ * - Large font sizes → suggest clamp()
12
+ * - Fixed height: 100vh → suggest 100dvh
13
+ * - Large padding/gap → suggest responsive reduction
14
+ * - Multiple fixed-width columns → suggest auto-fit grid
15
+ */
16
+
17
+ import type { StyleIR, IRRule, IRDeclaration, IRPass } from './style-ir.js';
18
+
19
+ // ============================================================================
20
+ // Types
21
+ // ============================================================================
22
+
23
+ export interface ResponsiveIssue {
24
+ ruleId: string;
25
+ selector: string;
26
+ property: string;
27
+ currentValue: string;
28
+ severity: 'error' | 'warning' | 'info';
29
+ category: 'overflow' | 'grid' | 'typography' | 'spacing' | 'viewport' | 'columns';
30
+ message: string;
31
+ suggestedFix: string;
32
+ autoFixAvailable: boolean;
33
+ affectedViewports: string[];
34
+ }
35
+
36
+ export interface ResponsiveReport {
37
+ issues: ResponsiveIssue[];
38
+ criticalCount: number;
39
+ warningCount: number;
40
+ infoCount: number;
41
+ summary: string;
42
+ }
43
+
44
+ // ============================================================================
45
+ // Detection Rules
46
+ // ============================================================================
47
+
48
+ const MOBILE_BREAKPOINT = 768;
49
+ const TABLET_BREAKPOINT = 1024;
50
+ const LARGE_FONT_THRESHOLD = 32; // px
51
+ const LARGE_PADDING_THRESHOLD = 48; // px
52
+ const LARGE_GAP_THRESHOLD = 32; // px
53
+ const MAX_GRID_COLUMNS = 2; // warn if more than this
54
+
55
+ /**
56
+ * Detect fixed pixel widths that will overflow mobile.
57
+ */
58
+ function detectFixedWidth(rule: IRRule): ResponsiveIssue[] {
59
+ const issues: ResponsiveIssue[] = [];
60
+
61
+ for (const decl of rule.declarations) {
62
+ if ((decl.property === 'width' || decl.property === 'max-width') &&
63
+ typeof decl.value === 'string') {
64
+ const pxMatch = decl.value.match(/^(\d+(\.\d+)?)px$/);
65
+ if (pxMatch) {
66
+ const px = parseFloat(pxMatch[1]);
67
+ if (px > MOBILE_BREAKPOINT) {
68
+ issues.push({
69
+ ruleId: rule.id,
70
+ selector: rule.selector,
71
+ property: decl.property,
72
+ currentValue: decl.value,
73
+ severity: px > TABLET_BREAKPOINT ? 'error' : 'warning',
74
+ category: 'overflow',
75
+ message: 'Fixed ' + decl.property + ': ' + decl.value + ' will overflow on viewports < ' + px + 'px',
76
+ suggestedFix: decl.property + ': min(100%, ' + decl.value + ');',
77
+ autoFixAvailable: true,
78
+ affectedViewports: ['mobile', 'tablet'],
79
+ });
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ return issues;
86
+ }
87
+
88
+ /**
89
+ * Detect grid columns that won't fit on mobile.
90
+ */
91
+ function detectGridColumns(rule: IRRule): ResponsiveIssue[] {
92
+ const issues: ResponsiveIssue[] = [];
93
+
94
+ for (const decl of rule.declarations) {
95
+ if ((decl.property === 'gridTemplateColumns' || decl.property === 'grid-template-columns') &&
96
+ typeof decl.value === 'string') {
97
+ // Count explicit columns: 1fr 1fr 1fr 1fr or 300px 300px 300px
98
+ const columns = decl.value.split(/\s+/).filter(c =>
99
+ c.includes('fr') || c.includes('px') || c.includes('%')
100
+ );
101
+ const explicitColumns = columns.length;
102
+
103
+ if (explicitColumns > MAX_GRID_COLUMNS) {
104
+ const isFixed = columns.every(c => c.includes('px'));
105
+ const suggestion = isFixed
106
+ ? 'repeat(auto-fit, minmax(' + columns[0] + ', 1fr))'
107
+ : 'repeat(auto-fit, minmax(250px, 1fr))';
108
+
109
+ issues.push({
110
+ ruleId: rule.id,
111
+ selector: rule.selector,
112
+ property: decl.property,
113
+ currentValue: decl.value,
114
+ severity: explicitColumns >= 4 ? 'error' : 'warning',
115
+ category: 'grid',
116
+ message: explicitColumns + ' columns will not fit on mobile screens (≤ ' + MOBILE_BREAKPOINT + 'px)',
117
+ suggestedFix: 'grid-template-columns: ' + suggestion + ';',
118
+ autoFixAvailable: true,
119
+ affectedViewports: ['mobile'],
120
+ });
121
+ }
122
+ }
123
+
124
+ // Detect auto-fit without minmax
125
+ if ((decl.property === 'gridTemplateColumns' || decl.property === 'grid-template-columns') &&
126
+ typeof decl.value === 'string' &&
127
+ decl.value.includes('auto-fit') &&
128
+ !decl.value.includes('minmax')) {
129
+ issues.push({
130
+ ruleId: rule.id,
131
+ selector: rule.selector,
132
+ property: decl.property,
133
+ currentValue: decl.value,
134
+ severity: 'info',
135
+ category: 'grid',
136
+ message: 'auto-fit without minmax() may collapse to 0 on empty containers. Consider: repeat(auto-fit, minmax(250px, 1fr))',
137
+ suggestedFix: 'grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));',
138
+ autoFixAvailable: true,
139
+ affectedViewports: ['all'],
140
+ });
141
+ }
142
+ }
143
+
144
+ return issues;
145
+ }
146
+
147
+ /**
148
+ * Detect large font sizes that need responsive scaling.
149
+ */
150
+ function detectLargeTypography(rule: IRRule): ResponsiveIssue[] {
151
+ const issues: ResponsiveIssue[] = [];
152
+
153
+ for (const decl of rule.declarations) {
154
+ if ((decl.property === 'fontSize' || decl.property === 'font-size') &&
155
+ typeof decl.value === 'string') {
156
+ const pxMatch = decl.value.match(/^(\d+(\.\d+)?)px$/);
157
+ if (pxMatch) {
158
+ const px = parseFloat(pxMatch[1]);
159
+ if (px > LARGE_FONT_THRESHOLD) {
160
+ const minSize = Math.round(px * 0.5);
161
+ issues.push({
162
+ ruleId: rule.id,
163
+ selector: rule.selector,
164
+ property: decl.property,
165
+ currentValue: decl.value,
166
+ severity: 'warning',
167
+ category: 'typography',
168
+ message: 'font-size: ' + decl.value + ' may be too large on mobile. Consider responsive scaling.',
169
+ suggestedFix: 'font-size: clamp(' + minSize + 'px, ' + Math.round(px / TABLET_BREAKPOINT * 100) + 'vw, ' + px + 'px);',
170
+ autoFixAvailable: true,
171
+ affectedViewports: ['mobile', 'tablet'],
172
+ });
173
+ }
174
+ }
175
+ }
176
+ }
177
+
178
+ return issues;
179
+ }
180
+
181
+ /**
182
+ * Detect large padding on fixed elements.
183
+ */
184
+ function detectLargeSpacing(rule: IRRule): ResponsiveIssue[] {
185
+ const issues: ResponsiveIssue[] = [];
186
+
187
+ for (const decl of rule.declarations) {
188
+ if ((decl.property === 'padding' || decl.property.startsWith('padding')) &&
189
+ typeof decl.value === 'string') {
190
+ const pxMatch = decl.value.match(/^(\d+(\.\d+)?)px$/);
191
+ if (pxMatch) {
192
+ const px = parseFloat(pxMatch[1]);
193
+ if (px > LARGE_PADDING_THRESHOLD) {
194
+ issues.push({
195
+ ruleId: rule.id,
196
+ selector: rule.selector,
197
+ property: decl.property,
198
+ currentValue: decl.value,
199
+ severity: 'info',
200
+ category: 'spacing',
201
+ message: decl.property + ': ' + decl.value + ' may be excessive on mobile. Consider reducing to ' + Math.round(px * 0.5) + 'px on small screens.',
202
+ suggestedFix: '@media (max-width: 768px) { ' + rule.selector + ' { ' + decl.property + ': ' + Math.round(px * 0.5) + 'px; } }',
203
+ autoFixAvailable: true,
204
+ affectedViewports: ['mobile'],
205
+ });
206
+ }
207
+ }
208
+ }
209
+ }
210
+
211
+ return issues;
212
+ }
213
+
214
+ /**
215
+ * Detect height: 100vh that should use dvh.
216
+ */
217
+ function detectViewportUnits(rule: IRRule): ResponsiveIssue[] {
218
+ const issues: ResponsiveIssue[] = [];
219
+
220
+ for (const decl of rule.declarations) {
221
+ if ((decl.property === 'height' || decl.property === 'min-height') &&
222
+ typeof decl.value === 'string' &&
223
+ decl.value.includes('100vh')) {
224
+ issues.push({
225
+ ruleId: rule.id,
226
+ selector: rule.selector,
227
+ property: decl.property,
228
+ currentValue: decl.value,
229
+ severity: 'warning',
230
+ category: 'viewport',
231
+ message: '100vh can cause issues on mobile browsers with dynamic toolbars. Consider 100dvh instead.',
232
+ suggestedFix: decl.property + ': 100dvh;',
233
+ autoFixAvailable: true,
234
+ affectedViewports: ['mobile'],
235
+ });
236
+ }
237
+ }
238
+
239
+ return issues;
240
+ }
241
+
242
+ /**
243
+ * Detect large gaps.
244
+ */
245
+ function detectLargeGaps(rule: IRRule): ResponsiveIssue[] {
246
+ const issues: ResponsiveIssue[] = [];
247
+
248
+ for (const decl of rule.declarations) {
249
+ if ((decl.property === 'gap' || decl.property === 'grid-gap') &&
250
+ typeof decl.value === 'string') {
251
+ const pxMatch = decl.value.match(/^(\d+(\.\d+)?)px$/);
252
+ if (pxMatch) {
253
+ const px = parseFloat(pxMatch[1]);
254
+ if (px > LARGE_GAP_THRESHOLD) {
255
+ issues.push({
256
+ ruleId: rule.id,
257
+ selector: rule.selector,
258
+ property: decl.property,
259
+ currentValue: decl.value,
260
+ severity: 'info',
261
+ category: 'spacing',
262
+ message: 'gap: ' + decl.value + ' may be too large on mobile. Consider reducing to ' + Math.round(px * 0.5) + 'px on small screens.',
263
+ suggestedFix: '@media (max-width: 768px) { ' + rule.selector + ' { gap: ' + Math.round(px * 0.5) + 'px; } }',
264
+ autoFixAvailable: true,
265
+ affectedViewports: ['mobile'],
266
+ });
267
+ }
268
+ }
269
+ }
270
+ }
271
+
272
+ return issues;
273
+ }
274
+
275
+ // ============================================================================
276
+ // IR Pass
277
+ // ============================================================================
278
+
279
+ /**
280
+ * Responsive Inference IR pass.
281
+ * Detects responsive issues and adds diagnostics with suggested fixes.
282
+ */
283
+ export const responsiveInferencePass: IRPass = (ir: StyleIR): StyleIR => {
284
+ const allIssues: ResponsiveIssue[] = [];
285
+
286
+ for (const rule of ir.rules) {
287
+ if (rule.isDead) continue;
288
+
289
+ allIssues.push(...detectFixedWidth(rule));
290
+ allIssues.push(...detectGridColumns(rule));
291
+ allIssues.push(...detectLargeTypography(rule));
292
+ allIssues.push(...detectLargeSpacing(rule));
293
+ allIssues.push(...detectViewportUnits(rule));
294
+ allIssues.push(...detectLargeGaps(rule));
295
+ }
296
+
297
+ // Convert issues to IR diagnostics
298
+ for (const issue of allIssues) {
299
+ ir.diagnostics.push({
300
+ id: 'responsive-' + issue.category + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6),
301
+ nodeId: issue.ruleId,
302
+ severity: issue.severity,
303
+ message: issue.message,
304
+ suggestion: issue.suggestedFix,
305
+ pass: 'responsive-inference',
306
+ });
307
+ }
308
+
309
+ // Store for reporting
310
+ ir.meta = ir.meta || {};
311
+ (ir.meta as any).responsiveIssues = allIssues;
312
+
313
+ return ir;
314
+ };
315
+
316
+ // ============================================================================
317
+ // Standalone API
318
+ // ============================================================================
319
+
320
+ /**
321
+ * Analyze declarations and return responsive issues.
322
+ */
323
+ export function analyzeResponsive(
324
+ selector: string,
325
+ declarations: Record<string, string | number>
326
+ ): ResponsiveIssue[] {
327
+ const rule: IRRule = {
328
+ id: 'temp-responsive',
329
+ selector,
330
+ declarations: Object.entries(declarations).map(([prop, value]) => ({
331
+ id: 'temp-' + prop,
332
+ property: prop,
333
+ value,
334
+ history: [],
335
+ meta: {},
336
+ })),
337
+ pseudoClasses: [],
338
+ atRules: [],
339
+ nestedRules: [],
340
+ conditions: [],
341
+ isDead: false,
342
+ specificity: 0,
343
+ hash: '',
344
+ source: {},
345
+ history: [],
346
+ meta: {},
347
+ };
348
+
349
+ return [
350
+ ...detectFixedWidth(rule),
351
+ ...detectGridColumns(rule),
352
+ ...detectLargeTypography(rule),
353
+ ...detectLargeSpacing(rule),
354
+ ...detectViewportUnits(rule),
355
+ ...detectLargeGaps(rule),
356
+ ];
357
+ }
358
+
359
+ /**
360
+ * Generate a full responsive report.
361
+ */
362
+ export function generateResponsiveReport(issues: ResponsiveIssue[]): ResponsiveReport {
363
+ const critical = issues.filter(i => i.severity === 'error');
364
+ const warnings = issues.filter(i => i.severity === 'warning');
365
+ const info = issues.filter(i => i.severity === 'info');
366
+
367
+ let summary: string;
368
+ if (issues.length === 0) {
369
+ summary = '✅ No responsive issues detected.';
370
+ } else if (critical.length > 0) {
371
+ summary = '❌ ' + critical.length + ' critical, ' + warnings.length + ' warnings, ' + info.length + ' suggestions.';
372
+ } else if (warnings.length > 0) {
373
+ summary = '⚠️ ' + warnings.length + ' warnings, ' + info.length + ' suggestions.';
374
+ } else {
375
+ summary = '💡 ' + info.length + ' responsive suggestions.';
376
+ }
377
+
378
+ return {
379
+ issues,
380
+ criticalCount: critical.length,
381
+ warningCount: warnings.length,
382
+ infoCount: info.length,
383
+ summary,
384
+ };
385
+ }
386
+
387
+ /**
388
+ * Auto-fix a single responsive issue.
389
+ */
390
+ export function autoFixIssue(issue: ResponsiveIssue): string {
391
+ return issue.suggestedFix;
392
+ }
393
+
394
+ /**
395
+ * Auto-fix all auto-fixable issues.
396
+ */
397
+ export function autoFixAll(issues: ResponsiveIssue[]): string[] {
398
+ return issues
399
+ .filter(i => i.autoFixAvailable)
400
+ .map(i => i.suggestedFix);
401
+ }
402
+
403
+ // ============================================================================
404
+ // Quick API
405
+ // ============================================================================
406
+
407
+ export const responsiveInference = {
408
+ analyze: analyzeResponsive,
409
+ report: generateResponsiveReport,
410
+ autoFix: autoFixIssue,
411
+ autoFixAll,
412
+ pass: responsiveInferencePass,
413
+ };
414
+
415
+ export default responsiveInference;