inkbridge 0.1.0-beta.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.
Files changed (62) hide show
  1. package/README.md +201 -0
  2. package/bin/inkhouse.mjs +171 -0
  3. package/code.js +11802 -0
  4. package/manifest.json +30 -0
  5. package/package.json +45 -0
  6. package/scanner/blob-placement-regression.ts +132 -0
  7. package/scanner/class-collector.ts +69 -0
  8. package/scanner/cli.ts +336 -0
  9. package/scanner/component-scanner.ts +2876 -0
  10. package/scanner/css-patch-regression.ts +112 -0
  11. package/scanner/css-token-reader-regression.ts +92 -0
  12. package/scanner/css-token-reader.ts +477 -0
  13. package/scanner/font-style-resolver-regression.ts +32 -0
  14. package/scanner/index.ts +9 -0
  15. package/scanner/radial-gradient-regression.ts +53 -0
  16. package/scanner/style-map.ts +149 -0
  17. package/scanner/tailwind-parser.ts +644 -0
  18. package/scanner/transform-math-regression.ts +42 -0
  19. package/scanner/types.ts +298 -0
  20. package/src/blob-placement.ts +111 -0
  21. package/src/change-detection.ts +204 -0
  22. package/src/class-utils.ts +105 -0
  23. package/src/clip-path-decorative.ts +194 -0
  24. package/src/color-resolver.ts +98 -0
  25. package/src/colors.ts +196 -0
  26. package/src/component-defs.ts +54 -0
  27. package/src/component-gen.ts +561 -0
  28. package/src/component-lookup.ts +82 -0
  29. package/src/config.ts +115 -0
  30. package/src/design-system.ts +59 -0
  31. package/src/dev-server.ts +173 -0
  32. package/src/figma-globals.d.ts +3 -0
  33. package/src/font-style-resolver.ts +171 -0
  34. package/src/github.ts +1465 -0
  35. package/src/icon-builder.ts +607 -0
  36. package/src/image-cache.ts +22 -0
  37. package/src/inline-text.ts +271 -0
  38. package/src/layout-parser.ts +667 -0
  39. package/src/layout-utils.ts +155 -0
  40. package/src/main.ts +687 -0
  41. package/src/node-ir.ts +595 -0
  42. package/src/pack-provider.ts +148 -0
  43. package/src/packs.ts +126 -0
  44. package/src/radial-gradient.ts +84 -0
  45. package/src/render-context.ts +138 -0
  46. package/src/responsive-analyzer.ts +139 -0
  47. package/src/state-analyzer.ts +143 -0
  48. package/src/story-builder.ts +1706 -0
  49. package/src/story-layout.ts +38 -0
  50. package/src/tailwind.ts +2379 -0
  51. package/src/text-builder.ts +116 -0
  52. package/src/text-line.ts +42 -0
  53. package/src/token-source.ts +43 -0
  54. package/src/tokens.ts +717 -0
  55. package/src/transform-math.ts +44 -0
  56. package/src/ui-builder.ts +1996 -0
  57. package/src/utility-resolver.ts +125 -0
  58. package/src/variables.ts +1042 -0
  59. package/src/width-solver.ts +466 -0
  60. package/templates/patch-tokens-route.ts +165 -0
  61. package/templates/scan-components-route.ts +57 -0
  62. package/ui.html +1222 -0
@@ -0,0 +1,644 @@
1
+ /**
2
+ * Tailwind Class Parser
3
+ *
4
+ * Parses Tailwind CSS classes and extracts styling information
5
+ * for mapping to Figma properties.
6
+ */
7
+
8
+ import type { ParsedTailwindClass, ColorMapping, SizeMapping } from './types';
9
+ import colors from 'tailwindcss/colors';
10
+
11
+ // ============================================================================
12
+ // Responsive Breakpoints (all standard Tailwind breakpoints)
13
+ // ============================================================================
14
+
15
+ export const RESPONSIVE_PREFIXES = [
16
+ 'sm:',
17
+ 'md:',
18
+ 'lg:',
19
+ 'xl:',
20
+ '2xl:',
21
+ 'min-[', // arbitrary min-width: min-[320px]:
22
+ 'max-sm:',
23
+ 'max-md:',
24
+ 'max-lg:',
25
+ 'max-xl:',
26
+ 'max-2xl:',
27
+ 'max-[', // arbitrary max-width: max-[600px]:
28
+ ] as const;
29
+
30
+ // ============================================================================
31
+ // Pseudo-element / Pseudo-class / Structural Modifiers
32
+ // ============================================================================
33
+
34
+ export const PSEUDO_MODIFIERS = [
35
+ // Pseudo-elements
36
+ 'before:',
37
+ 'after:',
38
+ 'placeholder:',
39
+ 'file:',
40
+ 'selection:',
41
+ 'marker:',
42
+ 'backdrop:',
43
+ 'first-line:',
44
+ 'first-letter:',
45
+ // Structural pseudo-classes
46
+ 'first:',
47
+ 'last:',
48
+ 'odd:',
49
+ 'even:',
50
+ 'only:',
51
+ 'first-of-type:',
52
+ 'last-of-type:',
53
+ 'only-of-type:',
54
+ 'empty:',
55
+ // Form pseudo-classes
56
+ 'required:',
57
+ 'optional:',
58
+ 'valid:',
59
+ 'invalid:',
60
+ 'in-range:',
61
+ 'out-of-range:',
62
+ 'read-only:',
63
+ 'read-write:',
64
+ 'autofill:',
65
+ 'checked:',
66
+ 'indeterminate:',
67
+ 'default:',
68
+ 'enabled:',
69
+ // Print / Motion / Contrast
70
+ 'print:',
71
+ 'motion-safe:',
72
+ 'motion-reduce:',
73
+ 'contrast-more:',
74
+ 'contrast-less:',
75
+ 'forced-colors:',
76
+ // Direction / Language
77
+ 'ltr:',
78
+ 'rtl:',
79
+ // Dark mode
80
+ 'dark:',
81
+ // Container queries
82
+ '@sm:',
83
+ '@md:',
84
+ '@lg:',
85
+ '@xl:',
86
+ '@2xl:',
87
+ // Supports / Has
88
+ 'supports-[',
89
+ 'has-[',
90
+ 'not-[',
91
+ // Portrait / Landscape
92
+ 'portrait:',
93
+ 'landscape:',
94
+ // Open
95
+ 'open:',
96
+ ] as const;
97
+
98
+ // ============================================================================
99
+ // Tailwind Spacing Scale (default values in pixels, assuming 1rem = 16px)
100
+ // ============================================================================
101
+
102
+ export const TAILWIND_SPACING: Record<string, number> = {
103
+ '0': 0,
104
+ 'px': 1,
105
+ '0.5': 2,
106
+ '1': 4,
107
+ '1.5': 6,
108
+ '2': 8,
109
+ '2.5': 10,
110
+ '3': 12,
111
+ '3.5': 14,
112
+ '4': 16,
113
+ '5': 20,
114
+ '6': 24,
115
+ '7': 28,
116
+ '8': 32,
117
+ '9': 36,
118
+ '10': 40,
119
+ '11': 44,
120
+ '12': 48,
121
+ '14': 56,
122
+ '16': 64,
123
+ '20': 80,
124
+ '24': 96,
125
+ '28': 112,
126
+ '32': 128,
127
+ '36': 144,
128
+ '40': 160,
129
+ '44': 176,
130
+ '48': 192,
131
+ '52': 208,
132
+ '56': 224,
133
+ '60': 240,
134
+ '64': 256,
135
+ '72': 288,
136
+ '80': 320,
137
+ '96': 384,
138
+ // Tailwind v4 named container sizes (rem × 16 → px)
139
+ 'xs': 320,
140
+ 'sm': 384,
141
+ 'md': 448,
142
+ 'lg': 512,
143
+ 'xl': 576,
144
+ '2xl': 672,
145
+ '3xl': 768,
146
+ '4xl': 896,
147
+ '5xl': 1024,
148
+ '6xl': 1152,
149
+ '7xl': 1280,
150
+ };
151
+
152
+ /**
153
+ * Resolve a spacing token to pixels.
154
+ * Handles the v3 named scale, v4 named container sizes, and v4's open numeric
155
+ * scale where any integer n maps to n × 4 px (e.g. w-100 = 400 px).
156
+ */
157
+ export function resolveSpacing(value: string): number | undefined {
158
+ const named = TAILWIND_SPACING[value];
159
+ if (named !== undefined) return named;
160
+ const n = parseFloat(value);
161
+ if (!Number.isNaN(n) && String(n) === value) return n * 4;
162
+ return undefined;
163
+ }
164
+
165
+ // ============================================================================
166
+ // State Modifiers
167
+ // ============================================================================
168
+
169
+ export const STATE_MODIFIERS = [
170
+ 'hover:',
171
+ 'focus:',
172
+ 'focus-visible:',
173
+ 'focus-within:',
174
+ 'active:',
175
+ 'disabled:',
176
+ 'aria-invalid:',
177
+ 'aria-selected:',
178
+ 'aria-expanded:',
179
+ 'data-[state=open]:',
180
+ 'data-[state=checked]:',
181
+ 'data-[state=active]:',
182
+ 'data-[disabled]:',
183
+ 'group-hover:',
184
+ 'group-focus:',
185
+ 'peer-focus:',
186
+ 'peer-disabled:',
187
+ ];
188
+
189
+ // ============================================================================
190
+ // Parsing Functions
191
+ // ============================================================================
192
+
193
+ /**
194
+ * All known modifier prefixes (state, responsive, pseudo, dark, etc.)
195
+ * Sorted longest-first so that e.g. `focus-visible:` matches before `focus:`.
196
+ */
197
+ const ALL_MODIFIERS: string[] = [
198
+ ...STATE_MODIFIERS,
199
+ ...RESPONSIVE_PREFIXES,
200
+ ...PSEUDO_MODIFIERS,
201
+ ].sort((a, b) => b.length - a.length);
202
+
203
+ /**
204
+ * Strip one modifier prefix from the front of a class string.
205
+ * Handles bracket-based modifiers like `data-[state=open]:`, `min-[320px]:`,
206
+ * `supports-[display:grid]:`, `has-[>img]:`, `not-[.disabled]:`, `max-[600px]:`.
207
+ * Returns the modifier string (including the trailing `:`) and the remainder,
208
+ * or null if no modifier is found.
209
+ */
210
+ function stripOneModifier(cls: string): { modifier: string; rest: string } | null {
211
+ // 1. Try bracket-based modifiers first (data-[...]:, aria-[...]:, min-[...]:, etc.)
212
+ const bracketMatch = cls.match(/^([a-z@-]*\[[^\]]*\]:)(.*)/);
213
+ if (bracketMatch) {
214
+ return { modifier: bracketMatch[1], rest: bracketMatch[2] };
215
+ }
216
+
217
+ // 2. Try known fixed modifiers
218
+ for (const mod of ALL_MODIFIERS) {
219
+ if (cls.startsWith(mod)) {
220
+ return { modifier: mod, rest: cls.slice(mod.length) };
221
+ }
222
+ }
223
+
224
+ return null;
225
+ }
226
+
227
+ /**
228
+ * Parse a single Tailwind class into its components.
229
+ *
230
+ * Handles stacked modifiers (e.g. `sm:hover:bg-primary`, `dark:focus:ring-2`)
231
+ * and arbitrary bracket selectors (e.g. `data-[state=open]:animate-in`).
232
+ */
233
+ export function parseTailwindClass(cls: string): ParsedTailwindClass {
234
+ const modifiers: string[] = [];
235
+ let utility = cls;
236
+
237
+ // Peel off all modifier layers
238
+ let result = stripOneModifier(utility);
239
+ while (result) {
240
+ modifiers.push(result.modifier);
241
+ utility = result.rest;
242
+ result = stripOneModifier(utility);
243
+ }
244
+
245
+ const modifier = modifiers.length > 0 ? modifiers.join('') : undefined;
246
+
247
+ // Categorise the modifiers
248
+ const responsive = modifiers.find(m =>
249
+ (RESPONSIVE_PREFIXES as readonly string[]).includes(m) || /^(min|max)-\[/.test(m)
250
+ );
251
+ const pseudo = modifiers.find(m =>
252
+ (PSEUDO_MODIFIERS as readonly string[]).includes(m)
253
+ );
254
+ const state = modifiers.find(m =>
255
+ STATE_MODIFIERS.includes(m)
256
+ );
257
+ const isDark = modifiers.includes('dark:');
258
+
259
+ // Handle opacity modifier in the utility (e.g. bg-muted/50)
260
+ let opacity: number | undefined;
261
+ const opacityMatch = utility.match(/^(.+)\/(\d+)$/);
262
+ if (opacityMatch) {
263
+ utility = opacityMatch[1];
264
+ opacity = parseInt(opacityMatch[2], 10);
265
+ }
266
+
267
+ // Handle arbitrary values (e.g. text-[13px], left-[calc(50%-11rem)])
268
+ let arbitrary: string | undefined;
269
+ const arbitraryMatch = utility.match(/^([a-z-]+)-\[(.+)\]$/);
270
+ if (arbitraryMatch) {
271
+ arbitrary = arbitraryMatch[2];
272
+ }
273
+
274
+ // Handle line-height slash notation (e.g. text-sm/6)
275
+ const slashNotation = cls.match(/^text-([a-z]+)\/(\d+)$/);
276
+
277
+ // Extract value from utility (e.g., "p-4" -> value = "4")
278
+ const match = utility.match(/^([a-z-]+)-(.+)$/);
279
+ const value = match?.[2];
280
+
281
+ return {
282
+ original: cls,
283
+ modifier,
284
+ modifiers,
285
+ utility,
286
+ value,
287
+ responsive,
288
+ pseudo,
289
+ state,
290
+ isDark,
291
+ opacity,
292
+ arbitrary,
293
+ lineHeight: slashNotation ? slashNotation[2] : undefined,
294
+ };
295
+ }
296
+
297
+ /**
298
+ * Parse a space-separated string of Tailwind classes
299
+ */
300
+ export function parseClassString(classString: string): ParsedTailwindClass[] {
301
+ return classString
302
+ .split(/\s+/)
303
+ .filter(Boolean)
304
+ .map(parseTailwindClass);
305
+ }
306
+
307
+ /**
308
+ * Extract color-related classes and map to token names
309
+ */
310
+ export function extractColorMappings(classes: string[]): ColorMapping[] {
311
+ const mappings: ColorMapping[] = [];
312
+
313
+ for (const cls of classes) {
314
+ const parsed = parseTailwindClass(cls);
315
+ const utility = parsed.utility;
316
+
317
+ // Background colors
318
+ if (utility.startsWith('bg-')) {
319
+ const tokenName = utility.slice(3);
320
+ // Filter out non-token values like opacity modifiers
321
+ if (!tokenName.includes('/') && !tokenName.match(/^(transparent|current|inherit|white|black)$/)) {
322
+ mappings.push({
323
+ tailwindClass: cls,
324
+ tokenName,
325
+ property: 'background',
326
+ });
327
+ }
328
+ }
329
+
330
+ // Text colors
331
+ if (utility.startsWith('text-') && !utility.match(/^text-(xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl)$/)) {
332
+ const tokenName = utility.slice(5);
333
+ if (!tokenName.includes('/') && !tokenName.match(/^(transparent|current|inherit|white|black)$/)) {
334
+ mappings.push({
335
+ tailwindClass: cls,
336
+ tokenName,
337
+ property: 'text',
338
+ });
339
+ }
340
+ }
341
+
342
+ // Border colors
343
+ if (utility.startsWith('border-') && !utility.match(/^border-(\d+|t|r|b|l|x|y)$/)) {
344
+ const tokenName = utility.slice(7);
345
+ if (!tokenName.includes('/') && !tokenName.match(/^(transparent|current|inherit|white|black)$/)) {
346
+ mappings.push({
347
+ tailwindClass: cls,
348
+ tokenName,
349
+ property: 'border',
350
+ });
351
+ }
352
+ }
353
+
354
+ // Ring colors
355
+ if (utility.startsWith('ring-') && !utility.match(/^ring-(\d+|inset|offset)$/)) {
356
+ const tokenName = utility.slice(5);
357
+ if (!tokenName.includes('/') && !tokenName.match(/^(transparent|current|inherit|white|black)$/)) {
358
+ mappings.push({
359
+ tailwindClass: cls,
360
+ tokenName,
361
+ property: 'ring',
362
+ });
363
+ }
364
+ }
365
+ }
366
+
367
+ return mappings;
368
+ }
369
+
370
+ /**
371
+ * Extract Tailwind palette tokens used in classes and map to color values.
372
+ */
373
+ export function extractPaletteTokens(classes: string[]): Record<string, string> {
374
+ const palette: Record<string, string> = {};
375
+ const prefixes = ['bg-', 'text-', 'border-', 'ring-', 'from-', 'to-', 'via-', 'fill-', 'stroke-'];
376
+
377
+ const resolvePaletteColor = (token: string): string | null => {
378
+ if (!token) return null;
379
+ if (token === 'transparent' || token === 'current' || token === 'inherit') return null;
380
+ const direct = (colors as any)[token];
381
+ if (typeof direct === 'string') return direct;
382
+ const parts = token.split('-');
383
+ if (parts.length < 2) return null;
384
+ const shade = parts[parts.length - 1];
385
+ const name = parts.slice(0, -1).join('-');
386
+ const group = (colors as any)[name];
387
+ if (group && typeof group === 'object' && group[shade]) return group[shade];
388
+ return null;
389
+ };
390
+
391
+ for (const cls of classes) {
392
+ const parsed = parseTailwindClass(cls);
393
+ const utility = parsed.utility;
394
+ for (const prefix of prefixes) {
395
+ if (!utility.startsWith(prefix)) continue;
396
+ const token = utility.slice(prefix.length);
397
+ if (!token || token.startsWith('[')) break;
398
+ const value = resolvePaletteColor(token);
399
+ if (value) palette[token] = value;
400
+ break;
401
+ }
402
+ }
403
+
404
+ return palette;
405
+ }
406
+
407
+ /**
408
+ * Extract size-related classes and convert to pixel values
409
+ */
410
+ export function extractSizeMappings(classes: string[]): SizeMapping[] {
411
+ const mappings: SizeMapping[] = [];
412
+
413
+ for (const cls of classes) {
414
+ const parsed = parseTailwindClass(cls);
415
+ const utility = parsed.utility;
416
+
417
+ // Height
418
+ if (utility.startsWith('h-')) {
419
+ const value = utility.slice(2);
420
+ const px = resolveSpacing(value);
421
+ if (px !== undefined) {
422
+ mappings.push({ tailwindClass: cls, property: 'height', value: px });
423
+ }
424
+ }
425
+
426
+ // Width
427
+ if (utility.startsWith('w-')) {
428
+ const value = utility.slice(2);
429
+ const px = resolveSpacing(value);
430
+ if (px !== undefined) {
431
+ mappings.push({ tailwindClass: cls, property: 'width', value: px });
432
+ }
433
+ }
434
+
435
+ // Padding (all sides)
436
+ if (utility.startsWith('p-') && !utility.startsWith('px-') && !utility.startsWith('py-') && !utility.startsWith('pt-') && !utility.startsWith('pr-') && !utility.startsWith('pb-') && !utility.startsWith('pl-')) {
437
+ const value = utility.slice(2);
438
+ const px = resolveSpacing(value);
439
+ if (px !== undefined) {
440
+ mappings.push({ tailwindClass: cls, property: 'padding', value: px });
441
+ }
442
+ }
443
+
444
+ // Padding X
445
+ if (utility.startsWith('px-')) {
446
+ const value = utility.slice(3);
447
+ const px = resolveSpacing(value);
448
+ if (px !== undefined) {
449
+ mappings.push({ tailwindClass: cls, property: 'padding', value: px });
450
+ }
451
+ }
452
+
453
+ // Padding Y
454
+ if (utility.startsWith('py-')) {
455
+ const value = utility.slice(3);
456
+ const px = resolveSpacing(value);
457
+ if (px !== undefined) {
458
+ mappings.push({ tailwindClass: cls, property: 'padding', value: px });
459
+ }
460
+ }
461
+
462
+ // Gap
463
+ if (utility.startsWith('gap-')) {
464
+ const value = utility.slice(4);
465
+ const px = resolveSpacing(value);
466
+ if (px !== undefined) {
467
+ mappings.push({ tailwindClass: cls, property: 'gap', value: px });
468
+ }
469
+ }
470
+
471
+ // Space-y, space-x (item spacing)
472
+ if (utility.startsWith('space-y-') || utility.startsWith('space-x-')) {
473
+ const value = utility.slice(8);
474
+ const px = resolveSpacing(value);
475
+ if (px !== undefined) {
476
+ mappings.push({ tailwindClass: cls, property: 'gap', value: px });
477
+ }
478
+ }
479
+ }
480
+
481
+ return mappings;
482
+ }
483
+
484
+ /**
485
+ * Group classes by their state modifier
486
+ */
487
+ export function groupClassesByState(classes: string[]): Record<string, string[]> {
488
+ const groups: Record<string, string[]> = {
489
+ default: [],
490
+ };
491
+
492
+ for (const cls of classes) {
493
+ const parsed = parseTailwindClass(cls);
494
+
495
+ if (parsed.state) {
496
+ // Normalize modifier to state name
497
+ let stateName = 'default';
498
+ if (parsed.state.includes('hover')) stateName = 'hover';
499
+ else if (parsed.state.includes('focus')) stateName = 'focus';
500
+ else if (parsed.state.includes('disabled') || parsed.state.includes('[disabled]')) stateName = 'disabled';
501
+ else if (parsed.state.includes('aria-invalid')) stateName = 'error';
502
+ else if (parsed.state.includes('active')) stateName = 'active';
503
+ else if (parsed.state.includes('checked')) stateName = 'checked';
504
+ else if (parsed.state.includes('open')) stateName = 'open';
505
+
506
+ if (!groups[stateName]) {
507
+ groups[stateName] = [];
508
+ }
509
+ groups[stateName].push(parsed.utility);
510
+ } else if (!parsed.modifier) {
511
+ groups.default.push(cls);
512
+ } else {
513
+ // Classes with only responsive/pseudo/dark modifiers go to default
514
+ groups.default.push(cls);
515
+ }
516
+ }
517
+
518
+ return groups;
519
+ }
520
+
521
+ /**
522
+ * Group classes by responsive breakpoint
523
+ */
524
+ export function groupClassesByBreakpoint(classes: string[]): Record<string, string[]> {
525
+ const groups: Record<string, string[]> = {
526
+ base: [],
527
+ };
528
+
529
+ for (const cls of classes) {
530
+ const parsed = parseTailwindClass(cls);
531
+
532
+ if (parsed.responsive) {
533
+ const bp = parsed.responsive.replace(/:$/, '');
534
+ if (!groups[bp]) groups[bp] = [];
535
+ groups[bp].push(parsed.utility);
536
+ } else {
537
+ groups.base.push(cls);
538
+ }
539
+ }
540
+
541
+ return groups;
542
+ }
543
+
544
+ /**
545
+ * Separate dark mode classes from light mode classes
546
+ */
547
+ export function groupClassesByColorScheme(classes: string[]): { light: string[]; dark: string[] } {
548
+ const light: string[] = [];
549
+ const dark: string[] = [];
550
+
551
+ for (const cls of classes) {
552
+ const parsed = parseTailwindClass(cls);
553
+ if (parsed.isDark) {
554
+ dark.push(parsed.utility);
555
+ } else {
556
+ light.push(cls);
557
+ }
558
+ }
559
+
560
+ return { light, dark };
561
+ }
562
+
563
+ /**
564
+ * Infer layout properties from a set of Tailwind classes
565
+ */
566
+ export function inferLayout(classes: string[]): import('./types').LayoutInfo {
567
+ const layout: import('./types').LayoutInfo = {
568
+ display: null,
569
+ direction: null,
570
+ };
571
+
572
+ for (const cls of classes) {
573
+ const parsed = parseTailwindClass(cls);
574
+ // Only look at base (non-responsive, non-state) classes for primary layout
575
+ if (parsed.modifier) continue;
576
+ const u = parsed.utility;
577
+
578
+ // Display
579
+ if (u === 'flex') layout.display = 'flex';
580
+ else if (u === 'grid') layout.display = 'grid';
581
+ else if (u === 'block') layout.display = 'block';
582
+ else if (u === 'inline' || u === 'inline-flex' || u === 'inline-block') layout.display = 'inline';
583
+ else if (u === 'hidden') layout.display = 'hidden';
584
+ else if (u === 'table') layout.display = 'table';
585
+ else if (u === 'contents') layout.display = 'contents';
586
+
587
+ // Direction
588
+ if (u === 'flex-row') layout.direction = 'row';
589
+ else if (u === 'flex-col') layout.direction = 'col';
590
+
591
+ // Wrap
592
+ if (u === 'flex-wrap') layout.wrap = true;
593
+ else if (u === 'flex-nowrap') layout.wrap = false;
594
+
595
+ // Gap
596
+ if (u.startsWith('gap-')) {
597
+ const val = u.slice(4);
598
+ const px = resolveSpacing(val);
599
+ if (px !== undefined) layout.gap = px;
600
+ }
601
+
602
+ // Padding
603
+ if (u.startsWith('p-') && !u.startsWith('px-') && !u.startsWith('py-') && !u.startsWith('pt-') && !u.startsWith('pb-') && !u.startsWith('pl-') && !u.startsWith('pr-') && !u.startsWith('ps-') && !u.startsWith('pe-')) {
604
+ const val = u.slice(2);
605
+ const px = resolveSpacing(val);
606
+ if (px !== undefined) layout.padding = { all: px };
607
+ }
608
+ if (u.startsWith('px-')) {
609
+ const val = u.slice(3);
610
+ const px = resolveSpacing(val);
611
+ if (px !== undefined) layout.padding = { ...layout.padding, x: px };
612
+ }
613
+ if (u.startsWith('py-')) {
614
+ const val = u.slice(3);
615
+ const px = resolveSpacing(val);
616
+ if (px !== undefined) layout.padding = { ...layout.padding, y: px };
617
+ }
618
+
619
+ // Alignment
620
+ if (u.startsWith('items-')) layout.alignment = u.slice(6);
621
+ if (u.startsWith('justify-')) layout.justification = u.slice(8);
622
+ }
623
+
624
+ // Default flex direction to row if display is flex and no explicit direction
625
+ if (layout.display === 'flex' && !layout.direction) {
626
+ layout.direction = 'row';
627
+ }
628
+
629
+ return layout;
630
+ }
631
+
632
+ /**
633
+ * Extract all unique color tokens used in a set of classes
634
+ */
635
+ export function extractColorTokens(classes: string[]): string[] {
636
+ const tokens = new Set<string>();
637
+ const colorMappings = extractColorMappings(classes);
638
+
639
+ for (const mapping of colorMappings) {
640
+ tokens.add(mapping.tokenName);
641
+ }
642
+
643
+ return Array.from(tokens);
644
+ }
@@ -0,0 +1,42 @@
1
+ import assert from 'node:assert/strict';
2
+ import {
3
+ centerPlacedRotationTransform,
4
+ rotationTransformAroundPointRadians,
5
+ } from '../src/transform-math';
6
+
7
+ function approxEqual(actual: number, expected: number, epsilon = 0.0001): void {
8
+ assert.ok(Math.abs(actual - expected) <= epsilon, `expected ${expected}, got ${actual}`);
9
+ }
10
+
11
+ function assertMatrix(
12
+ actual: [[number, number, number], [number, number, number]],
13
+ expected: [[number, number, number], [number, number, number]],
14
+ ): void {
15
+ approxEqual(actual[0][0], expected[0][0]);
16
+ approxEqual(actual[0][1], expected[0][1]);
17
+ approxEqual(actual[0][2], expected[0][2]);
18
+ approxEqual(actual[1][0], expected[1][0]);
19
+ approxEqual(actual[1][1], expected[1][1]);
20
+ approxEqual(actual[1][2], expected[1][2]);
21
+ }
22
+
23
+ // For 45° around normalized center, this should match previous hardcoded
24
+ // Tailwind gradient direction matrix semantics.
25
+ const angle = Math.PI / 4;
26
+ const rotateCenter = rotationTransformAroundPointRadians(angle, 0.5, 0.5);
27
+ const cos = Math.cos(angle);
28
+ const sin = Math.sin(angle);
29
+ assertMatrix(rotateCenter, [
30
+ [cos, -sin, 0.5 * (1 - cos + sin)],
31
+ [sin, cos, 0.5 * (1 - sin - cos)],
32
+ ]);
33
+
34
+ // Hero reference case: width=1155, height=678, center=(32,339), rotation=30deg.
35
+ // We keep this as a regression lock for blob center placement.
36
+ const blobTransform = centerPlacedRotationTransform(1155, 678, 32, 339, 30);
37
+ assertMatrix(blobTransform, [
38
+ [Math.cos(Math.PI / 6), -Math.sin(Math.PI / 6), -298.62967068551336],
39
+ [Math.sin(Math.PI / 6), Math.cos(Math.PI / 6), -243.33261188292465],
40
+ ]);
41
+
42
+ console.log('transform math regression passed');