chaincss 2.1.39 → 2.3.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 (33) hide show
  1. package/dist/compiler/accessibility-engine.d.ts +57 -0
  2. package/dist/compiler/constraint-solver.d.ts +85 -0
  3. package/dist/compiler/css-if-transpiler.d.ts +33 -0
  4. package/dist/compiler/design-orchestrator.d.ts +119 -0
  5. package/dist/compiler/intent-api.d.ts +73 -0
  6. package/dist/compiler/intent-engine.d.ts +19 -1
  7. package/dist/compiler/layout-intelligence.d.ts +71 -0
  8. package/dist/compiler/pass-manager.d.ts +157 -0
  9. package/dist/compiler/pattern-learner.d.ts +112 -0
  10. package/dist/compiler/responsive-inference.d.ts +63 -0
  11. package/dist/compiler/scroll-timeline.d.ts +91 -0
  12. package/dist/compiler/semantic-tokens.d.ts +57 -0
  13. package/dist/compiler/source-optimizer.d.ts +109 -0
  14. package/dist/compiler/style-ir.d.ts +183 -0
  15. package/dist/index.d.ts +23 -0
  16. package/dist/index.js +4126 -2
  17. package/package.json +1 -1
  18. package/src/compiler/accessibility-engine.ts +502 -0
  19. package/src/compiler/constraint-solver.ts +407 -0
  20. package/src/compiler/css-if-transpiler.ts +117 -0
  21. package/src/compiler/design-orchestrator.ts +322 -0
  22. package/src/compiler/intent-api.ts +505 -0
  23. package/src/compiler/intent-engine.ts +291 -1
  24. package/src/compiler/layout-intelligence.ts +697 -0
  25. package/src/compiler/pass-manager.ts +657 -0
  26. package/src/compiler/pattern-learner.ts +398 -0
  27. package/src/compiler/responsive-inference.ts +415 -0
  28. package/src/compiler/scroll-timeline.ts +284 -0
  29. package/src/compiler/semantic-tokens.ts +468 -0
  30. package/src/compiler/source-optimizer.ts +541 -0
  31. package/src/compiler/style-ir.ts +495 -0
  32. package/src/index.ts +209 -0
  33. package/ROADMAP.md +0 -31
@@ -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;
@@ -0,0 +1,284 @@
1
+ // src/compiler/scroll-timeline.ts
2
+ /**
3
+ * Scroll-Driven Animations Engine
4
+ *
5
+ * Compiles timeline-based animation descriptions into:
6
+ * 1. Native CSS scroll-timeline / view-timeline (Chromium 115+)
7
+ * 2. @supports fallback with JavaScript polyfill hint
8
+ *
9
+ * API inspiration: GSAP's ScrollTrigger, but compiles to 0kb JS.
10
+ */
11
+
12
+ // ============================================================================
13
+ // Types
14
+ // ============================================================================
15
+
16
+ export interface ScrollTimelineConfig {
17
+ /** Name of the timeline */
18
+ name: string;
19
+ /** What drives the timeline */
20
+ source: 'scroll' | 'view';
21
+ /** The scrollable element (default: nearest scrollable ancestor) */
22
+ scroller?: 'nearest' | 'root' | 'self' | string;
23
+ /** Scroll axis */
24
+ axis?: 'block' | 'inline' | 'x' | 'y';
25
+ /** For view timelines: when does the element enter/exit */
26
+ inset?: string | { start: string; end: string };
27
+ /** Timeline range (for view timelines) */
28
+ range?: 'cover' | 'contain' | 'entry' | 'exit' | 'entry-crossing' | 'exit-crossing' | string;
29
+ }
30
+
31
+ export interface KeyframeStep {
32
+ /** Percentage or keyword (e.g., '0%', 'from', 'to') */
33
+ offset: string;
34
+ /** CSS properties at this keyframe */
35
+ properties: Record<string, string | number>;
36
+ /** Easing for this segment */
37
+ easing?: string;
38
+ }
39
+
40
+ export interface ScrollAnimation {
41
+ /** Selector to animate */
42
+ selector: string;
43
+ /** Timeline configuration */
44
+ timeline: ScrollTimelineConfig;
45
+ /** Keyframes */
46
+ keyframes: KeyframeStep[];
47
+ /** Animation duration (maps to timeline range) */
48
+ duration?: string;
49
+ /** Fill mode */
50
+ fill?: 'none' | 'forwards' | 'backwards' | 'both';
51
+ /** Iteration count */
52
+ iterations?: number | 'infinite';
53
+ /** Delay before starting */
54
+ delay?: string;
55
+ }
56
+
57
+ export interface ScrollTimelineResult {
58
+ /** Native CSS for scroll-driven animation */
59
+ css: string;
60
+ /** The @keyframes name generated */
61
+ animationName: string;
62
+ /** The timeline name generated */
63
+ timelineName: string;
64
+ /** Fallback CSS for browsers without scroll-timeline */
65
+ fallback: string;
66
+ }
67
+
68
+ // ============================================================================
69
+ // Presets — common scroll animations
70
+ // ============================================================================
71
+
72
+ export const SCROLL_PRESETS: Record<string, ScrollAnimation> = {
73
+ fadeIn: {
74
+ selector: '',
75
+ timeline: { name: 'fade-in', source: 'view', range: 'entry' },
76
+ keyframes: [
77
+ { offset: '0%', properties: { opacity: '0', transform: 'translateY(20px)' } },
78
+ { offset: '100%', properties: { opacity: '1', transform: 'translateY(0)' } },
79
+ ],
80
+ },
81
+ fadeOut: {
82
+ selector: '',
83
+ timeline: { name: 'fade-out', source: 'view', range: 'exit' },
84
+ keyframes: [
85
+ { offset: '0%', properties: { opacity: '1' } },
86
+ { offset: '100%', properties: { opacity: '0' } },
87
+ ],
88
+ },
89
+ scaleIn: {
90
+ selector: '',
91
+ timeline: { name: 'scale-in', source: 'view', range: 'entry' },
92
+ keyframes: [
93
+ { offset: '0%', properties: { opacity: '0', transform: 'scale(0.8)' } },
94
+ { offset: '100%', properties: { opacity: '1', transform: 'scale(1)' } },
95
+ ],
96
+ },
97
+ slideLeft: {
98
+ selector: '',
99
+ timeline: { name: 'slide-left', source: 'view', range: 'entry' },
100
+ keyframes: [
101
+ { offset: '0%', properties: { opacity: '0', transform: 'translateX(-40px)' } },
102
+ { offset: '100%', properties: { opacity: '1', transform: 'translateX(0)' } },
103
+ ],
104
+ },
105
+ slideRight: {
106
+ selector: '',
107
+ timeline: { name: 'slide-right', source: 'view', range: 'entry' },
108
+ keyframes: [
109
+ { offset: '0%', properties: { opacity: '0', transform: 'translateX(40px)' } },
110
+ { offset: '100%', properties: { opacity: '1', transform: 'translateX(0)' } },
111
+ ],
112
+ },
113
+ parallax: {
114
+ selector: '',
115
+ timeline: { name: 'parallax', source: 'scroll', scroller: 'root' },
116
+ keyframes: [
117
+ { offset: '0%', properties: { transform: 'translateY(0)' } },
118
+ { offset: '100%', properties: { transform: 'translateY(-20%)' } },
119
+ ],
120
+ },
121
+ stickyReveal: {
122
+ selector: '',
123
+ timeline: { name: 'sticky-reveal', source: 'view', range: 'contain' },
124
+ keyframes: [
125
+ { offset: '0%', properties: { opacity: '0', clipPath: 'inset(0 0 100% 0)' } },
126
+ { offset: '50%', properties: { opacity: '1', clipPath: 'inset(0 0 0% 0)' } },
127
+ { offset: '100%', properties: { opacity: '1', clipPath: 'inset(0 0 0% 0)' } },
128
+ ],
129
+ },
130
+ };
131
+
132
+ // ============================================================================
133
+ // Core Compiler
134
+ // ============================================================================
135
+
136
+ let animCounter = 0;
137
+
138
+ function generateName(prefix: string): string {
139
+ return prefix + '-' + (animCounter++).toString(36);
140
+ }
141
+
142
+ /**
143
+ * Compile a scroll animation into CSS.
144
+ *
145
+ * Output includes:
146
+ * - @keyframes definition
147
+ * - animation-timeline property
148
+ * - animation-range (for view timelines)
149
+ * - @supports fallback for older browsers
150
+ */
151
+ export function compileScrollAnimation(animation: ScrollAnimation): ScrollTimelineResult {
152
+ const animName = animation.timeline.name || generateName('scroll-anim');
153
+ const timelineName = '--' + animName + '-tl';
154
+
155
+ let css = '';
156
+
157
+ // 1. Define the scroll timeline
158
+ css += '/* Scroll Timeline: ' + animName + ' */\n';
159
+
160
+ if (animation.timeline.source === 'view') {
161
+ // view-timeline
162
+ const range = animation.timeline.range || 'entry';
163
+ css += animation.selector + ' {\n';
164
+ css += ' view-timeline-name: ' + timelineName + ';\n';
165
+ css += ' view-timeline-axis: ' + (animation.timeline.axis || 'block') + ';\n';
166
+ if (animation.timeline.inset) {
167
+ const inset = typeof animation.timeline.inset === 'string'
168
+ ? animation.timeline.inset
169
+ : animation.timeline.inset.start + ' ' + animation.timeline.inset.end;
170
+ css += ' view-timeline-inset: ' + inset + ';\n';
171
+ }
172
+ css += '}\n\n';
173
+ } else {
174
+ // scroll-timeline
175
+ const scroller = animation.timeline.scroller === 'root' ? 'root'
176
+ : animation.timeline.scroller === 'self' ? 'self'
177
+ : animation.timeline.scroller || 'nearest';
178
+ css += animation.selector + ' {\n';
179
+ if (scroller === 'root' || scroller === 'self' || scroller === 'nearest') {
180
+ css += ' scroll-timeline-name: ' + timelineName + ';\n';
181
+ css += ' scroll-timeline-axis: ' + (animation.timeline.axis || 'block') + ';\n';
182
+ }
183
+ css += '}\n\n';
184
+ }
185
+
186
+ // 2. Define @keyframes
187
+ css += '@keyframes ' + animName + ' {\n';
188
+ for (const step of animation.keyframes) {
189
+ css += ' ' + step.offset + ' {\n';
190
+ for (const [prop, value] of Object.entries(step.properties)) {
191
+ const kebabProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
192
+ css += ' ' + kebabProp + ': ' + value + ';\n';
193
+ }
194
+ css += ' }\n';
195
+ }
196
+ css += '}\n\n';
197
+
198
+ // 3. Apply animation to target
199
+ const targetSelector = animation.selector + ' > *' || animation.selector;
200
+ css += '/* Apply animation to children */\n';
201
+ css += targetSelector + ' {\n';
202
+ css += ' animation: ' + animName + ' linear both;\n';
203
+ css += ' animation-timeline: ' + timelineName + ';\n';
204
+ if (animation.timeline.source === 'view') {
205
+ const range = animation.timeline.range || 'entry';
206
+ css += ' animation-range: ' + range + ';\n';
207
+ }
208
+ if (animation.delay) css += ' animation-delay: ' + animation.delay + ';\n';
209
+ css += '}\n\n';
210
+
211
+ // 4. @supports fallback
212
+ css += '/* Fallback for browsers without scroll-timeline */\n';
213
+ css += '@supports not (animation-timeline: scroll()) {\n';
214
+ css += ' ' + targetSelector + ' {\n';
215
+ css += ' /* Use JS polyfill: https://github.com/flackr/scroll-timeline */\n';
216
+ css += ' animation: ' + animName + ' 1s ease both;\n';
217
+ css += ' }\n';
218
+ css += '}\n';
219
+
220
+ return {
221
+ css,
222
+ animationName: animName,
223
+ timelineName,
224
+ fallback: '',
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Compile multiple scroll animations for a page.
230
+ */
231
+ export function compileScrollAnimations(animations: ScrollAnimation[]): string {
232
+ let css = '/* ============================================================\n';
233
+ css += ' ChainCSS Scroll-Driven Animations\n';
234
+ css += ' Generated: ' + new Date().toISOString() + '\n';
235
+ css += ' ============================================================ */\n\n';
236
+
237
+ for (const animation of animations) {
238
+ const result = compileScrollAnimation(animation);
239
+ css += result.css;
240
+ }
241
+
242
+ return css;
243
+ }
244
+
245
+ /**
246
+ * Create a scroll animation from a preset.
247
+ */
248
+ export function createScrollAnimation(
249
+ preset: keyof typeof SCROLL_PRESETS,
250
+ selector: string,
251
+ overrides?: Partial<ScrollAnimation>
252
+ ): ScrollAnimation {
253
+ const base = SCROLL_PRESETS[preset];
254
+ if (!base) throw new Error('Unknown scroll preset: ' + preset);
255
+
256
+ return {
257
+ ...base,
258
+ selector,
259
+ timeline: { ...base.timeline, ...overrides?.timeline },
260
+ keyframes: overrides?.keyframes || base.keyframes,
261
+ ...overrides,
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Get available scroll animation presets.
267
+ */
268
+ export function getScrollPresets(): string[] {
269
+ return Object.keys(SCROLL_PRESETS);
270
+ }
271
+
272
+ // ============================================================================
273
+ // Exports
274
+ // ============================================================================
275
+
276
+ export const scrollTimeline = {
277
+ compile: compileScrollAnimation,
278
+ compileAll: compileScrollAnimations,
279
+ create: createScrollAnimation,
280
+ presets: SCROLL_PRESETS,
281
+ getPresets: getScrollPresets,
282
+ };
283
+
284
+ export default scrollTimeline;