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,407 @@
1
+ // src/compiler/constraint-solver.ts
2
+ /**
3
+ * Constraint-Based Styling Engine
4
+ *
5
+ * Declare relationships, not values. The solver resolves them to CSS.
6
+ *
7
+ * @example
8
+ * .constrain('width', '< parent')
9
+ * .constrain('height', '= width * 0.5')
10
+ * .constrain('sidebar', 'sticky until footer')
11
+ * .constrain('columns', '>= 3 when > 768px')
12
+ */
13
+
14
+ import type { StyleIR, IRRule, IRDeclaration, IRPass } from './style-ir.js';
15
+ import { createDeclaration } from './style-ir.js';
16
+
17
+ // ============================================================================
18
+ // Types
19
+ // ============================================================================
20
+
21
+ export type ConstraintOperator = '<' | '>' | '<=' | '>=' | '=' | '!=' | '≈';
22
+
23
+ export type ConstraintTarget =
24
+ | 'parent'
25
+ | 'viewport'
26
+ | 'sibling'
27
+ | 'self'
28
+ | string; // CSS value or selector
29
+
30
+ export interface Constraint {
31
+ property: string;
32
+ operator: ConstraintOperator;
33
+ expression: string;
34
+ target?: ConstraintTarget;
35
+ condition?: string;
36
+ }
37
+
38
+ export interface ResolvedConstraint {
39
+ constraint: Constraint;
40
+ cssProperty: string;
41
+ cssValue: string;
42
+ method: 'direct' | 'calc' | 'aspect-ratio' | 'container-query' | 'sticky' | 'clamp' | 'custom-property' | 'color-mix';
43
+ explanation: string;
44
+ }
45
+
46
+ // ============================================================================
47
+ // Expression Parser
48
+ // ============================================================================
49
+
50
+ /**
51
+ * Tokenize a constraint expression like "width * 0.5" or "clamp(14, parent.width / 20, 24)"
52
+ */
53
+ function tokenize(expr: string): string[] {
54
+ const tokens: string[] = [];
55
+ let current = '';
56
+ let inParens = 0;
57
+
58
+ for (const char of expr) {
59
+ if (char === '(') { inParens++; current += char; }
60
+ else if (char === ')') { inParens--; current += char; }
61
+ else if (char === ' ' && inParens === 0) {
62
+ if (current) tokens.push(current);
63
+ current = '';
64
+ } else {
65
+ current += char;
66
+ }
67
+ }
68
+ if (current) tokens.push(current);
69
+ return tokens;
70
+ }
71
+
72
+ /**
73
+ * Parse a constraint expression into structured form.
74
+ */
75
+ function parseExpression(expr: string): {
76
+ left: string;
77
+ operator: string;
78
+ right: string;
79
+ isFunction: boolean;
80
+ functionName?: string;
81
+ functionArgs?: string[];
82
+ } {
83
+ const trimmed = expr.trim();
84
+
85
+ // Check for function calls: clamp(14, parent.width / 20, 24)
86
+ const funcMatch = trimmed.match(/^([a-zA-Z]+)\((.+)\)$/);
87
+ if (funcMatch) {
88
+ const args = funcMatch[2].split(',').map(a => a.trim());
89
+ return {
90
+ left: '',
91
+ operator: 'function',
92
+ right: '',
93
+ isFunction: true,
94
+ functionName: funcMatch[1],
95
+ functionArgs: args,
96
+ };
97
+ }
98
+
99
+ // Check for operators: *, /, +, -
100
+ const tokens = tokenize(trimmed);
101
+ if (tokens.length === 3) {
102
+ return {
103
+ left: tokens[0],
104
+ operator: tokens[1],
105
+ right: tokens[2],
106
+ isFunction: false,
107
+ };
108
+ }
109
+
110
+ // Single value
111
+ return {
112
+ left: trimmed,
113
+ operator: '',
114
+ right: '',
115
+ isFunction: false,
116
+ };
117
+ }
118
+
119
+ // ============================================================================
120
+ // Reference Resolver
121
+ // ============================================================================
122
+
123
+ const KNOWN_REFERENCES: Record<string, string> = {
124
+ 'parent': '100%',
125
+ 'parent.width': '100%',
126
+ 'parent.height': '100%',
127
+ 'viewport': '100vw',
128
+ 'viewport.width': '100vw',
129
+ 'viewport.height': '100vh',
130
+ 'self': '100%',
131
+ 'self.width': '100%',
132
+ 'self.height': '100%',
133
+ };
134
+
135
+ function resolveReference(ref: string): string {
136
+ return KNOWN_REFERENCES[ref] || ref;
137
+ }
138
+
139
+ // ============================================================================
140
+ // Solver
141
+ // ============================================================================
142
+
143
+ /**
144
+ * Resolve a single constraint into a CSS property+value.
145
+ */
146
+ export function resolveConstraint(constraint: Constraint, context?: Record<string, string>): ResolvedConstraint {
147
+ const { property, operator, expression } = constraint;
148
+ const parsed = parseExpression(expression);
149
+
150
+ // --- 1. Size constraints (width < parent) ---
151
+ if (operator === '<' && expression === 'parent') {
152
+ return {
153
+ constraint,
154
+ cssProperty: 'max-' + property,
155
+ cssValue: '100%',
156
+ method: 'direct',
157
+ explanation: property + ' < parent → max-' + property + ': 100%',
158
+ };
159
+ }
160
+
161
+ if (operator === '>' && expression === 'parent') {
162
+ return {
163
+ constraint,
164
+ cssProperty: 'min-' + property,
165
+ cssValue: '100%',
166
+ method: 'direct',
167
+ explanation: property + ' > parent → min-' + property + ': 100%',
168
+ };
169
+ }
170
+
171
+ // --- 2. Math expression (height = width * 0.5) ---
172
+ if (operator === '=' && parsed.operator === '*') {
173
+ const leftRef = resolveReference(parsed.left);
174
+ const rightNum = parseFloat(parsed.right);
175
+
176
+ if (!isNaN(rightNum)) {
177
+ // For width/height relations, use aspect-ratio
178
+ if ((property === 'height' && parsed.left === 'width') ||
179
+ (property === 'width' && parsed.left === 'height')) {
180
+ const ratio = rightNum;
181
+ const gcd = findGCD(Math.round(ratio * 100), 100);
182
+ const num = Math.round(ratio * 100) / gcd;
183
+ const den = 100 / gcd;
184
+ return {
185
+ constraint,
186
+ cssProperty: 'aspect-ratio',
187
+ cssValue: num + ' / ' + den,
188
+ method: 'aspect-ratio',
189
+ explanation: property + ' = ' + expression + ' → aspect-ratio: ' + num + '/' + den,
190
+ };
191
+ }
192
+
193
+ // General math: use calc()
194
+ return {
195
+ constraint,
196
+ cssProperty: property,
197
+ cssValue: 'calc(' + leftRef + ' * ' + rightNum + ')',
198
+ method: 'calc',
199
+ explanation: property + ' = ' + expression + ' → calc(' + leftRef + ' * ' + rightNum + ')',
200
+ };
201
+ }
202
+ }
203
+
204
+ // Expression with division: width / 20
205
+ if (operator === '=' && parsed.operator === '/') {
206
+ const leftRef = resolveReference(parsed.left);
207
+ const rightNum = parseFloat(parsed.right);
208
+ if (!isNaN(rightNum)) {
209
+ return {
210
+ constraint,
211
+ cssProperty: property,
212
+ cssValue: 'calc(' + leftRef + ' / ' + rightNum + ')',
213
+ method: 'calc',
214
+ explanation: property + ' = ' + expression + ' → calc(' + leftRef + ' / ' + rightNum + ')',
215
+ };
216
+ }
217
+ }
218
+
219
+ // --- 3. Function expressions (clamp, min, max) ---
220
+ if (parsed.isFunction && parsed.functionName) {
221
+ const resolvedArgs = (parsed.functionArgs || []).map(resolveReference);
222
+ return {
223
+ constraint,
224
+ cssProperty: property,
225
+ cssValue: parsed.functionName + '(' + resolvedArgs.join(', ') + ')',
226
+ method: parsed.functionName as any,
227
+ explanation: property + ' = ' + expression + ' → ' + parsed.functionName + '()',
228
+ };
229
+ }
230
+
231
+ // --- 4. Simple value assignment ---
232
+ if (operator === '=' && !parsed.operator) {
233
+ const resolved = resolveReference(expression);
234
+ return {
235
+ constraint,
236
+ cssProperty: property,
237
+ cssValue: resolved,
238
+ method: 'direct',
239
+ explanation: property + ' = ' + expression + ' → ' + resolved,
240
+ };
241
+ }
242
+
243
+ // --- 5. Viewport-relative ---
244
+ if (operator === '=' && expression.includes('vw') || expression.includes('vh')) {
245
+ return {
246
+ constraint,
247
+ cssProperty: property,
248
+ cssValue: expression,
249
+ method: 'direct',
250
+ explanation: property + ' = ' + expression,
251
+ };
252
+ }
253
+
254
+ // Fallback: pass through as calc()
255
+ return {
256
+ constraint,
257
+ cssProperty: property,
258
+ cssValue: expression,
259
+ method: 'direct',
260
+ explanation: property + ' = ' + expression + ' (passthrough)',
261
+ };
262
+ }
263
+
264
+ /**
265
+ * Resolve sticky-until constraint.
266
+ * "sticky until footer" → position: sticky + scroll-driven animation
267
+ */
268
+ export function resolveStickyUntil(selector: string, untilSelector: string): ResolvedConstraint {
269
+ return {
270
+ constraint: {
271
+ property: 'position',
272
+ operator: '=',
273
+ expression: 'sticky until ' + untilSelector,
274
+ },
275
+ cssProperty: 'position',
276
+ cssValue: 'sticky; top: 0; animation: sticky-' + selector.replace('.', '') + ' 1s linear both; animation-timeline: scroll(); animation-range: contain 0% contain 100%',
277
+ method: 'sticky',
278
+ explanation: 'sticky until ' + untilSelector + ' → position: sticky + scroll-timeline',
279
+ };
280
+ }
281
+
282
+ // ============================================================================
283
+ // Container Query Constraint
284
+ // ============================================================================
285
+
286
+ /**
287
+ * Resolve ">= N when > Xpx" constraints to container queries.
288
+ */
289
+ export function resolveContainerQuery(
290
+ property: string,
291
+ operator: string,
292
+ value: string,
293
+ condition: string
294
+ ): { atRule: { type: string; query: string; declarations: Array<{ property: string; value: string }> }; explanation: string } {
295
+ const widthMatch = condition.match(/>\s*(\d+)(px|rem|em)?/);
296
+ if (widthMatch) {
297
+ const width = widthMatch[1] + (widthMatch[2] || 'px');
298
+ return {
299
+ atRule: {
300
+ type: 'container',
301
+ query: '(min-width: ' + width + ')',
302
+ declarations: [{ property, value }],
303
+ },
304
+ explanation: property + ' ' + operator + ' ' + value + ' when > ' + width + ' → @container (min-width: ' + width + ')',
305
+ };
306
+ }
307
+ return {
308
+ atRule: { type: 'container', query: condition, declarations: [{ property, value }] },
309
+ explanation: property + ' when ' + condition + ' → @container ' + condition,
310
+ };
311
+ }
312
+
313
+ // ============================================================================
314
+ // Constraint Solver Pass (for IR Pipeline)
315
+ // ============================================================================
316
+
317
+ /**
318
+ * IR Pass: resolve all constraints in the IR to concrete CSS.
319
+ */
320
+ export const constraintSolverPass: IRPass = (ir: StyleIR): StyleIR => {
321
+ for (const rule of ir.rules) {
322
+ // Check for constraint metadata
323
+ const constraints: Constraint[] = rule.meta._constraints || [];
324
+ if (constraints.length === 0) continue;
325
+
326
+ for (const constraint of constraints) {
327
+ const resolved = resolveConstraint(constraint);
328
+
329
+ // Add the resolved declaration
330
+ rule.declarations.push({
331
+ id: 'constraint-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6),
332
+ property: resolved.cssProperty,
333
+ value: resolved.cssValue,
334
+ history: [{
335
+ pass: 'constraint-solver',
336
+ action: 'resolved-constraint',
337
+ timestamp: Date.now(),
338
+ reason: resolved.explanation,
339
+ }],
340
+ meta: { constraint },
341
+ });
342
+ }
343
+ }
344
+ return ir;
345
+ };
346
+
347
+ // ============================================================================
348
+ // Chain API Extension
349
+ // ============================================================================
350
+
351
+ /**
352
+ * Parse a chain-style constraint call into the Constraint format.
353
+ *
354
+ * @example
355
+ * parseConstraint('width', '< parent')
356
+ * // => { property: 'width', operator: '<', expression: 'parent' }
357
+ */
358
+ export function parseConstraint(property: string, expression: string): Constraint {
359
+ // Detect operator from expression
360
+ let operator: ConstraintOperator = '=';
361
+ let cleanExpr = expression;
362
+
363
+ const opMatch = expression.match(/^([<>=!≈]+)\s*(.*)/);
364
+ if (opMatch) {
365
+ operator = opMatch[1] as ConstraintOperator;
366
+ cleanExpr = opMatch[2];
367
+ }
368
+
369
+ // Detect condition: ">= 3 when > 768px"
370
+ let condition: string | undefined;
371
+ const condMatch = cleanExpr.match(/^(.+)\s+when\s+(.+)$/);
372
+ if (condMatch) {
373
+ cleanExpr = condMatch[1];
374
+ condition = condMatch[2];
375
+ }
376
+
377
+ return {
378
+ property,
379
+ operator,
380
+ expression: cleanExpr,
381
+ condition,
382
+ };
383
+ }
384
+
385
+ // ============================================================================
386
+ // Utilities
387
+ // ============================================================================
388
+
389
+ function findGCD(a: number, b: number): number {
390
+ return b === 0 ? a : findGCD(b, a % b);
391
+ }
392
+
393
+ // ============================================================================
394
+ // Quick API
395
+ // ============================================================================
396
+
397
+ export const constraintSolver = {
398
+ resolve: resolveConstraint,
399
+ resolveStickyUntil,
400
+ resolveContainerQuery,
401
+ parseConstraint,
402
+ parseExpression,
403
+ resolveReference,
404
+ pass: constraintSolverPass,
405
+ };
406
+
407
+ export default constraintSolver;