inkhouse 0.1.0-beta.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 (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 +145 -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,105 @@
1
+ import { type JsxNode, type JsxElement, splitClassName } from './node-ir';
2
+
3
+ export type ComponentDefResolver = (name: string) => any | null;
4
+
5
+ export function mergeClasses(base: string[], extra: string[]): string[] {
6
+ const seen: Record<string, boolean> = {};
7
+ const out: string[] = [];
8
+ for (const cls of base || []) {
9
+ if (!cls || seen[cls]) continue;
10
+ seen[cls] = true;
11
+ out.push(cls);
12
+ }
13
+ for (const cls of extra || []) {
14
+ if (!cls || seen[cls]) continue;
15
+ if (cls === 'rounded' || cls.indexOf('rounded-') === 0) {
16
+ const nextOut: string[] = [];
17
+ for (const existing of out) {
18
+ if (existing === 'rounded' || existing.indexOf('rounded-') === 0) {
19
+ delete seen[existing];
20
+ continue;
21
+ }
22
+ nextOut.push(existing);
23
+ }
24
+ out.length = 0;
25
+ for (const existing of nextOut) out.push(existing);
26
+ }
27
+ seen[cls] = true;
28
+ out.push(cls);
29
+ }
30
+ return out;
31
+ }
32
+
33
+ export function getCompoundClasses(def: any, tagName: string): string[] {
34
+ if (!def || def.type !== 'compound' || !def.subComponents) return [];
35
+ const target = tagName.toLowerCase();
36
+ let fallback: any = null;
37
+ for (const sub of def.subComponents) {
38
+ if (!sub || !sub.name) continue;
39
+ const subName = String(sub.name).toLowerCase();
40
+ if (subName === target) return sub.classes || [];
41
+ if (!fallback && (sub.slot === 'container' || subName === String(def.name || '').toLowerCase())) {
42
+ fallback = sub;
43
+ }
44
+ }
45
+ if (fallback && String(def.name || '').toLowerCase() === target) {
46
+ return fallback.classes || [];
47
+ }
48
+ return [];
49
+ }
50
+
51
+ export function treeHasFullWidth(
52
+ node: JsxNode | undefined,
53
+ parentCompoundDef: any | null,
54
+ options: {
55
+ getComponentDefByName: ComponentDefResolver;
56
+ normalizeComponentDef: (def: any) => any;
57
+ hasWidthHintInClasses: (classes: string[]) => boolean;
58
+ propsContainWidthHint: (props: Record<string, any> | undefined) => boolean;
59
+ }
60
+ ): boolean {
61
+ if (!node || node.type !== 'element') return false;
62
+ const el = node as JsxElement;
63
+ let classes = splitClassName(el.props && el.props.className);
64
+ let nextParentCompoundDef = parentCompoundDef || null;
65
+
66
+ if (el.isComponent) {
67
+ const compDef = options.getComponentDefByName(el.tagName);
68
+ if (compDef) {
69
+ const normalizedDef = options.normalizeComponentDef(compDef);
70
+ if (normalizedDef.type === 'compound') {
71
+ nextParentCompoundDef = normalizedDef;
72
+ const compoundClasses = getCompoundClasses(normalizedDef, el.tagName);
73
+ if (compoundClasses.length > 0) {
74
+ classes = mergeClasses(compoundClasses, classes);
75
+ }
76
+ }
77
+
78
+ const baseClasses = normalizedDef.baseClasses || normalizedDef.classes || [];
79
+ if (baseClasses.length > 0) {
80
+ classes = mergeClasses(baseClasses, classes);
81
+ }
82
+
83
+ const variants = normalizedDef.variantClasses || {};
84
+ const defaults = normalizedDef.defaultVariants || {};
85
+ for (const key in variants) {
86
+ const value = (el.props && el.props[key]) || defaults[key];
87
+ if (value && variants[key] && variants[key][value]) {
88
+ classes = mergeClasses(variants[key][value], classes);
89
+ }
90
+ }
91
+ } else if (parentCompoundDef) {
92
+ const inherited = getCompoundClasses(parentCompoundDef, el.tagName);
93
+ if (inherited.length > 0) {
94
+ classes = mergeClasses(inherited, classes);
95
+ }
96
+ }
97
+ }
98
+
99
+ if (options.hasWidthHintInClasses(classes)) return true;
100
+ if (options.propsContainWidthHint(el.props)) return true;
101
+ for (const child of el.children || []) {
102
+ if (treeHasFullWidth(child, nextParentCompoundDef, options)) return true;
103
+ }
104
+ return false;
105
+ }
@@ -0,0 +1,194 @@
1
+ import { getClassesForBreakpoint } from './responsive-analyzer';
2
+ import { applyTailwindStylesToFrame, markAbsoluteNode, markPositionInfo } from './tailwind';
3
+ import { parseUtilityClass } from './utility-resolver';
4
+ import { resolveBlobDimensions, resolveBlobPlacement } from './blob-placement';
5
+ import { centerPlacedRotationTransform } from './transform-math';
6
+
7
+ declare const figma: any;
8
+
9
+ const DEBUG_BLOB_PLACEMENT = false;
10
+
11
+ export type DecorativeClipPathParams = {
12
+ clipPathPolygon: string;
13
+ classes: string[];
14
+ contextMaxWidth?: number;
15
+ colorGroup: Record<string, string>;
16
+ radiusGroup: Record<string, string> | null;
17
+ theme: string;
18
+ };
19
+
20
+ export function parseClipPathFromStyle(style: string | Record<string, string> | undefined): string | null {
21
+ if (!style) return null;
22
+ if (typeof style === 'object') {
23
+ const clipPath = (style as any).clipPath || (style as any)['clip-path'];
24
+ if (!clipPath || typeof clipPath !== 'string') return null;
25
+ const match = clipPath.match(/polygon\s*\(\s*([^)]+)\s*\)/);
26
+ if (!match) return null;
27
+ return match[1].trim();
28
+ }
29
+ const match = (style as string).match(/polygon\s*\(\s*([^)]+)\s*\)/);
30
+ if (!match) return null;
31
+ return match[1].trim();
32
+ }
33
+
34
+ export function buildDecorativeClipPathNode(params: DecorativeClipPathParams): SceneNode | null {
35
+ const { clipPathPolygon, classes, contextMaxWidth, colorGroup, radiusGroup, theme } = params;
36
+ const activeClasses = (contextMaxWidth != null && contextMaxWidth >= 640)
37
+ ? getClassesForBreakpoint(classes, 'sm')
38
+ : classes;
39
+ const { vectorWidth, vectorHeight } = resolveBlobDimensions(activeClasses);
40
+ const vector = createVectorFromPolygon(clipPathPolygon, vectorWidth, vectorHeight);
41
+ if (!vector) return null;
42
+
43
+ const blobNode: SceneNode = vector;
44
+ vector.name = 'gradient-blob';
45
+ const clippedClasses = stripBlurUtilities(activeClasses);
46
+ applyTailwindStylesToFrame(vector as any, clippedClasses, colorGroup, radiusGroup, theme);
47
+
48
+ const containerWidth = contextMaxWidth;
49
+ if (containerWidth) {
50
+ const placement = resolveBlobPlacement(activeClasses, containerWidth, vectorWidth, vectorHeight);
51
+ const cssRotateDeg = placement.cssRotateDeg;
52
+ const topOffset = placement.topOffset;
53
+
54
+ const mask = figma.createFrame();
55
+ mask.name = 'gradient-blob-mask';
56
+ mask.layoutMode = 'NONE';
57
+ mask.primaryAxisSizingMode = 'FIXED';
58
+ mask.counterAxisSizingMode = 'FIXED';
59
+ mask.fills = [];
60
+ mask.strokes = [];
61
+ mask.clipsContent = false;
62
+ mask.resize(containerWidth, vectorHeight);
63
+
64
+ const blobBox = figma.createFrame();
65
+ blobBox.name = 'gradient-blob-box';
66
+ blobBox.layoutMode = 'NONE';
67
+ blobBox.primaryAxisSizingMode = 'FIXED';
68
+ blobBox.counterAxisSizingMode = 'FIXED';
69
+ blobBox.fills = [];
70
+ blobBox.strokes = [];
71
+ blobBox.resize(vectorWidth, vectorHeight);
72
+ blobBox.appendChild(blobNode);
73
+ vector.x = 0;
74
+ vector.y = 0;
75
+ mask.appendChild(blobBox);
76
+
77
+ const usedMatrix = Math.abs(cssRotateDeg) > 0.001
78
+ ? setTransformFromCenter(blobBox as SceneNode, vectorWidth, vectorHeight, placement.desiredCenterX, placement.desiredCenterY, cssRotateDeg)
79
+ : false;
80
+ if (!usedMatrix) {
81
+ blobBox.x = placement.desiredCenterX - vectorWidth / 2;
82
+ blobBox.y = 0;
83
+ if (Math.abs(cssRotateDeg) > 0.001) {
84
+ try {
85
+ blobBox.rotation = -cssRotateDeg;
86
+ } catch (_err) {
87
+ // ignore rotation errors
88
+ }
89
+ }
90
+ }
91
+ if (DEBUG_BLOB_PLACEMENT) {
92
+ console.log('[blob-place]', {
93
+ containerWidth,
94
+ vectorWidth,
95
+ vectorHeight,
96
+ desiredCenterX: placement.desiredCenterX,
97
+ desiredCenterY: placement.desiredCenterY,
98
+ topOffset,
99
+ cssRotateDeg,
100
+ usedMatrix,
101
+ });
102
+ }
103
+ return mask;
104
+ }
105
+
106
+ if (contextMaxWidth == null) {
107
+ const placement = resolveBlobPlacement(activeClasses, 0, vectorWidth, vectorHeight);
108
+ const cssRotateDeg = placement.cssRotateDeg;
109
+ if (Math.abs(cssRotateDeg) > 0.001) {
110
+ try {
111
+ (blobNode as any).rotation = -cssRotateDeg;
112
+ } catch (_err) {
113
+ // ignore rotation errors
114
+ }
115
+ }
116
+ markAbsoluteNode(blobNode);
117
+ markPositionInfo(blobNode, { left: placement.desiredCenterX - vectorWidth / 2, top: placement.topOffset });
118
+ }
119
+ return blobNode;
120
+ }
121
+
122
+ function createVectorFromPolygon(
123
+ polygonStr: string,
124
+ width: number,
125
+ height: number
126
+ ): VectorNode | null {
127
+ try {
128
+ const points = parsePolygonPoints(polygonStr, width, height);
129
+ if (!points || points.length < 3) return null;
130
+
131
+ let pathData = `M ${points[0].x} ${points[0].y}`;
132
+ for (let i = 1; i < points.length; i++) {
133
+ pathData += ` L ${points[i].x} ${points[i].y}`;
134
+ }
135
+ pathData += ' Z';
136
+
137
+ const vector = figma.createVector();
138
+ vector.vectorPaths = [{ windingRule: 'EVENODD', data: pathData }];
139
+ vector.resize(width, height);
140
+ vector.strokes = [];
141
+ vector.fills = [];
142
+ return vector;
143
+ } catch (_err) {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ function parsePolygonPoints(
149
+ polygonStr: string,
150
+ width: number,
151
+ height: number
152
+ ): { x: number; y: number }[] | null {
153
+ const points = polygonStr.split(',').map(p => {
154
+ const parts = p.trim().split(/\s+/);
155
+ if (parts.length < 2) return null;
156
+ const x = parseFloat(parts[0].replace('%', '')) / 100;
157
+ const y = parseFloat(parts[1].replace('%', '')) / 100;
158
+ if (isNaN(x) || isNaN(y)) return null;
159
+ return { x: x * width, y: y * height };
160
+ }).filter(Boolean) as { x: number; y: number }[];
161
+ return points.length >= 3 ? points : null;
162
+ }
163
+
164
+ function stripBlurUtilities(classes: string[]): string[] {
165
+ const next: string[] = [];
166
+ for (let i = 0; i < classes.length; i++) {
167
+ const cls = classes[i];
168
+ const atom = parseUtilityClass(cls);
169
+ if (!atom.utility) {
170
+ next.push(cls);
171
+ continue;
172
+ }
173
+ if (/^blur(?:-.+)?$/.test(atom.utility)) continue;
174
+ next.push(cls);
175
+ }
176
+ return next;
177
+ }
178
+
179
+ function setTransformFromCenter(
180
+ node: SceneNode,
181
+ width: number,
182
+ height: number,
183
+ centerX: number,
184
+ centerY: number,
185
+ rotationDeg: number
186
+ ): boolean {
187
+ const transform = centerPlacedRotationTransform(width, height, centerX, centerY, rotationDeg);
188
+ try {
189
+ (node as any).relativeTransform = transform;
190
+ return true;
191
+ } catch (_err) {
192
+ return false;
193
+ }
194
+ }
@@ -0,0 +1,98 @@
1
+ import { TOKENS, getThemeColors } from './tokens';
2
+ import type { RGB } from './colors';
3
+
4
+ const NON_COLOR_TEXT_PREFIXES = new Set([
5
+ 'xs', 'sm', 'base', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl', '7xl', '8xl', '9xl',
6
+ 'left', 'center', 'right', 'justify', 'start', 'end', 'wrap', 'nowrap', 'balance', 'pretty',
7
+ ]);
8
+ const NON_COLOR_TEXT_TOKENS = new Set([
9
+ 'transparent',
10
+ 'current',
11
+ 'inherit',
12
+ ]);
13
+
14
+ function getBaseClass(value: string): string | null {
15
+ if (!value) return null;
16
+ if (value.indexOf(':') !== -1) return null;
17
+ return value;
18
+ }
19
+
20
+ function isRgbObject(value: unknown): value is RGB {
21
+ if (!value || typeof value !== 'object') return false;
22
+ const obj = value as Record<string, unknown>;
23
+ return typeof obj.r === 'number' && typeof obj.g === 'number' && typeof obj.b === 'number';
24
+ }
25
+
26
+ export function extractTextColorToken(classes: string[]): string | null {
27
+ let token: string | null = null;
28
+ for (const cls of classes) {
29
+ const base = getBaseClass(cls);
30
+ if (!base || !base.startsWith('text-')) continue;
31
+ const raw = base.slice('text-'.length);
32
+ if (!raw || raw.startsWith('[') || NON_COLOR_TEXT_PREFIXES.has(raw) || NON_COLOR_TEXT_TOKENS.has(raw)) {
33
+ continue;
34
+ }
35
+ token = raw;
36
+ }
37
+ if (!token) return null;
38
+ const match = token.match(/^(.+)\/(\d+)$/);
39
+ return match ? match[1] : token;
40
+ }
41
+
42
+ export function resolveColorFromToken(
43
+ token: string,
44
+ colorGroup: Record<string, string>,
45
+ theme: string
46
+ ): string | null {
47
+ if (!token) return null;
48
+ const match = token.match(/^(.+)\/(\d+)$/);
49
+ const actualToken = match ? match[1] : token;
50
+ if (colorGroup[actualToken]) return colorGroup[actualToken];
51
+ const themeColors = getThemeColors(TOKENS, theme);
52
+ if (themeColors[actualToken]) return themeColors[actualToken];
53
+ const themeNames = Object.keys(TOKENS).filter((name) => name !== 'core');
54
+ for (const themeName of themeNames) {
55
+ const block = TOKENS[themeName];
56
+ if (!block || !('color' in block) || !block.color) continue;
57
+ if (block.color[actualToken]) return block.color[actualToken];
58
+ }
59
+ return null;
60
+ }
61
+
62
+ export function extractCssVarToken(value: unknown): string | null {
63
+ if (typeof value !== 'string') return null;
64
+ const match = value.match(/var\(--([^)]+)\)/);
65
+ if (!match) return null;
66
+ const token = match[1].replace(/^color-/, '');
67
+ return token || null;
68
+ }
69
+
70
+ export function resolveTextColorValue(
71
+ value: unknown,
72
+ token: string | null | undefined,
73
+ colorGroup: Record<string, string>,
74
+ theme: string
75
+ ): string | RGB | null {
76
+ if (value) {
77
+ if (isRgbObject(value)) {
78
+ return value;
79
+ }
80
+ const varToken = extractCssVarToken(value);
81
+ if (varToken) {
82
+ return resolveColorFromToken(varToken, colorGroup, theme) || String(value);
83
+ }
84
+ return typeof value === 'string' ? value : null;
85
+ }
86
+ if (token) return resolveColorFromToken(token, colorGroup, theme);
87
+ return null;
88
+ }
89
+
90
+ export function resolveTextColorFallback(
91
+ classes: string[],
92
+ colorGroup: Record<string, string>,
93
+ theme: string
94
+ ): string | null {
95
+ const token = extractTextColorToken(classes);
96
+ if (!token) return null;
97
+ return resolveColorFromToken(token, colorGroup, theme);
98
+ }
package/src/colors.ts ADDED
@@ -0,0 +1,196 @@
1
+ // --- Color parsing: OKLCH -> sRGB ---
2
+
3
+ export interface RGB {
4
+ r: number;
5
+ g: number;
6
+ b: number;
7
+ a?: number;
8
+ }
9
+
10
+ interface ColorIndexEntry {
11
+ token: string;
12
+ rgb: RGB;
13
+ }
14
+
15
+ // Module-level state for color index
16
+ const COLOR_INDEX: ColorIndexEntry[] = [];
17
+
18
+ export const DEBUG = false;
19
+
20
+ export function debug(...args: unknown[]): void {
21
+ if (!DEBUG) return;
22
+ try {
23
+ console.log.apply(console, ["[TailwindTokens]"].concat([].slice.call(args)));
24
+ } catch (err) {}
25
+ }
26
+
27
+ export function clamp01(x: number): number {
28
+ return Math.max(0, Math.min(1, x));
29
+ }
30
+
31
+ export function oklchToRgb(str: string): RGB {
32
+ const s = str.trim();
33
+ if (!s.toLowerCase().startsWith('oklch(') || !s.endsWith(')')) {
34
+ throw new Error('Unsupported color: ' + str);
35
+ }
36
+ let inner = s.slice(6, -1).trim();
37
+ let alpha = '1';
38
+ const slash = inner.indexOf('/');
39
+ if (slash !== -1) {
40
+ alpha = inner.slice(slash + 1).trim();
41
+ inner = inner.slice(0, slash).trim();
42
+ }
43
+ const parts = inner.split(' ').filter(Boolean);
44
+ if (parts.length < 3) throw new Error('Invalid OKLCH: ' + str);
45
+ const [Ls, Cs, Hs] = parts;
46
+ const L = Ls.endsWith('%') ? parseFloat(Ls) / 100 : parseFloat(Ls);
47
+ const C = parseFloat(Cs);
48
+ const H = parseFloat(Hs);
49
+ const A = alpha.endsWith('%') ? parseFloat(alpha) / 100 : parseFloat(alpha || '1');
50
+ const hr = (H * Math.PI) / 180;
51
+ const a = C * Math.cos(hr);
52
+ const b = C * Math.sin(hr);
53
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
54
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
55
+ const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
56
+ const l3 = l_ ** 3, m3 = m_ ** 3, s3 = s_ ** 3;
57
+ let r = 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3;
58
+ let g = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3;
59
+ let b2 = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.7076147010 * s3;
60
+ function lin2srgb(c: number): number {
61
+ return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
62
+ }
63
+ return { r: clamp01(lin2srgb(r)), g: clamp01(lin2srgb(g)), b: clamp01(lin2srgb(b2)), a: clamp01(A) };
64
+ }
65
+
66
+ export function parseColor(value: unknown): RGB {
67
+ if (value && typeof value === 'object') {
68
+ const obj = value as Record<string, unknown>;
69
+ if (typeof obj.r === 'number' && typeof obj.g === 'number' && typeof obj.b === 'number') {
70
+ return {
71
+ r: clamp01(obj.r as number),
72
+ g: clamp01(obj.g as number),
73
+ b: clamp01(obj.b as number),
74
+ a: obj.a == null ? 1 : clamp01(obj.a as number)
75
+ };
76
+ }
77
+ }
78
+ const v = String(value || '').trim();
79
+ if (v.toLowerCase().startsWith('oklch(')) return oklchToRgb(v);
80
+ if (/^#([0-9a-f]{3}|([0-9a-f]{2}){3})$/i.test(v)) {
81
+ let hex = v.slice(1);
82
+ if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
83
+ const n = parseInt(hex, 16);
84
+ return { r: ((n >> 16) & 255) / 255, g: ((n >> 8) & 255) / 255, b: (n & 255) / 255, a: 1 };
85
+ }
86
+ return { r: 1, g: 1, b: 1, a: 1 };
87
+ }
88
+
89
+ export function colorToLabel(value: unknown): string {
90
+ if (value && typeof value === 'object') {
91
+ const obj = value as Record<string, unknown>;
92
+ if (typeof obj.r === 'number') {
93
+ const a = obj.a == null ? 1 : Math.round((obj.a as number) * 100) / 100;
94
+ return 'rgba(' + Math.round((obj.r as number) * 255) + ', ' + Math.round((obj.g as number) * 255) + ', ' + Math.round((obj.b as number) * 255) + ', ' + a + ')';
95
+ }
96
+ }
97
+ return String(value || '');
98
+ }
99
+
100
+ export function isPlainObject(val: unknown): val is Record<string, unknown> {
101
+ return !!val && typeof val === 'object' && !Array.isArray(val);
102
+ }
103
+
104
+ export function mergeTokens(base: unknown, override: unknown): unknown {
105
+ if (!isPlainObject(base)) return override;
106
+ const out: Record<string, unknown> = {};
107
+ for (const key in base) out[key] = base[key];
108
+ for (const key in (override as Record<string, unknown>)) {
109
+ if (isPlainObject(base[key]) && isPlainObject((override as Record<string, unknown>)[key])) {
110
+ out[key] = mergeTokens(base[key], (override as Record<string, unknown>)[key]);
111
+ } else {
112
+ out[key] = (override as Record<string, unknown>)[key];
113
+ }
114
+ }
115
+ return out;
116
+ }
117
+
118
+ export function normalizeThemeName(value: string): string | null {
119
+ const v = String(value || '').toLowerCase();
120
+ if (v.indexOf('primary') !== -1) return 'primary';
121
+ if (v.indexOf('secondary') !== -1) return 'secondary';
122
+ if (v.indexOf('tertiary') !== -1) return 'tertiary';
123
+ if (v.indexOf('light') !== -1) return 'primary';
124
+ if (v.indexOf('dark') !== -1) return 'secondary';
125
+ if (v.indexOf('core') !== -1 || v.indexOf('base') !== -1) return 'core';
126
+ return null;
127
+ }
128
+
129
+ export function normalizeGroupName(value: string): string | null {
130
+ const v = String(value || '').toLowerCase();
131
+ if (v === 'colors' || v === 'colour' || v === 'colours') return 'color';
132
+ if (v === 'radii' || v === 'radius' || v === 'border-radius' || v === 'borderradius') return 'radius';
133
+ if (v === 'space' || v === 'spacing') return 'spacing';
134
+ if (v === 'fontsize' || v === 'font-size' || v === 'fontsizes') return 'fontsize';
135
+ if (v === 'font' || v === 'fonts') return 'font';
136
+ if (v === 'fontweight' || v === 'font-weight' || v === 'weights') return 'fontWeight';
137
+ if (v === 'tracking' || v === 'letterspacing' || v === 'letter-spacing') return 'tracking';
138
+ if (v === 'typography' || v === 'text') return 'typography';
139
+ if (v === 'color') return 'color';
140
+ return null;
141
+ }
142
+
143
+ export function normalizeTypographyProp(value: string): string {
144
+ const v = String(value || '').toLowerCase();
145
+ if (v === 'fontsize' || v === 'font-size') return 'fontSize';
146
+ if (v === 'lineheight' || v === 'line-height') return 'lineHeight';
147
+ if (v === 'letterspacing' || v === 'letter-spacing') return 'letterSpacing';
148
+ if (v === 'fontfamily' || v === 'font-family') return 'fontFamily';
149
+ if (v === 'fontweight' || v === 'font-weight') return 'fontWeight';
150
+ return value;
151
+ }
152
+
153
+ export function normalizeSizeValue(value: unknown): string {
154
+ if (typeof value === 'number') return value + 'px';
155
+ const raw = String(value || '').trim();
156
+ if (!raw) return raw;
157
+ if (raw.startsWith('calc(')) return raw;
158
+ if (/^-?[0-9.]+(px|rem|em|%)$/i.test(raw)) return raw;
159
+ if (/^-?[0-9.]+$/.test(raw)) return raw + 'px';
160
+ return raw;
161
+ }
162
+
163
+ // --- Color index for nearest-color lookups ---
164
+
165
+ export function rebuildColorIndex(tokens: any): void {
166
+ COLOR_INDEX.length = 0;
167
+ for (const theme of Object.keys(tokens)) {
168
+ const col: Record<string, unknown> = (tokens[theme] && tokens[theme].color) ? tokens[theme].color : {};
169
+ for (const [token, value] of Object.entries(col)) {
170
+ try {
171
+ COLOR_INDEX.push({ token, rgb: parseColor(value) });
172
+ } catch (err) {
173
+ // ignore bad color
174
+ }
175
+ }
176
+ }
177
+ }
178
+
179
+ export function dist2(a: RGB, b: RGB): number {
180
+ const dr = a.r - b.r, dg = a.g - b.g, db = a.b - b.b;
181
+ return dr * dr + dg * dg + db * db;
182
+ }
183
+
184
+ export function nearestColorToken(rgb: RGB | null): string | null {
185
+ if (!rgb) return null;
186
+ let bestToken: string | null = null;
187
+ let best = Infinity;
188
+ for (const entry of COLOR_INDEX) {
189
+ const d = dist2(rgb, entry.rgb);
190
+ if (d < best) {
191
+ best = d;
192
+ bestToken = entry.token;
193
+ }
194
+ }
195
+ return bestToken;
196
+ }
@@ -0,0 +1,54 @@
1
+ import { COMPONENT_DEFS } from './tokens';
2
+
3
+ // --- Component Definition Helpers ---
4
+ // These helpers let the plugin dynamically read from scanned component definitions
5
+
6
+ export function getComponentDef(name: string): any | null {
7
+ if (!COMPONENT_DEFS || !COMPONENT_DEFS.components) return null;
8
+ for (const raw of COMPONENT_DEFS.components) {
9
+ const def = raw && raw.analysis ? raw.analysis : raw;
10
+ if (def && def.name && def.name.toLowerCase() === name.toLowerCase()) {
11
+ return def;
12
+ }
13
+ }
14
+ return null;
15
+ }
16
+
17
+ export function getCVAComponentVariants(name: string): any | null {
18
+ const def = getComponentDef(name);
19
+ if (!def || def.type !== 'cva') return null;
20
+ return def.variants;
21
+ }
22
+
23
+ export function getCVAComponentClasses(name: string, variantName?: string, variantValue?: string): string[] {
24
+ const def = getComponentDef(name);
25
+ if (!def || def.type !== 'cva') return [];
26
+ const classes: string[] = def.baseClasses.slice();
27
+ if (variantName && variantValue && def.variantClasses && def.variantClasses[variantName]) {
28
+ const variantClasses = def.variantClasses[variantName][variantValue];
29
+ if (variantClasses) {
30
+ return classes.concat(variantClasses);
31
+ }
32
+ }
33
+ return classes;
34
+ }
35
+
36
+ export function getStateComponentStates(name: string): string[] | null {
37
+ const def = getComponentDef(name);
38
+ if (!def || def.type !== 'state') return null;
39
+ return Object.keys(def.states);
40
+ }
41
+
42
+ export function getCompoundSubComponents(name: string): any[] | null {
43
+ const def = getComponentDef(name);
44
+ if (!def || def.type !== 'compound') return null;
45
+ return def.subComponents;
46
+ }
47
+
48
+ export function listAllComponents(): { name: string; type: string }[] {
49
+ if (!COMPONENT_DEFS || !COMPONENT_DEFS.components) return [];
50
+ return COMPONENT_DEFS.components.map((raw: any) => {
51
+ const def = raw && raw.analysis ? raw.analysis : raw;
52
+ return { name: def?.name, type: def?.type };
53
+ });
54
+ }