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,148 @@
1
+ import { debug } from './colors';
2
+ import { waitForUIReady } from './dev-server';
3
+ import { normalizePack } from './packs';
4
+ import type { Pack } from './packs';
5
+ import type { ProjectConfig } from './config';
6
+
7
+ type PackFetchResult = {
8
+ requestId: string;
9
+ data: any | null;
10
+ source?: string;
11
+ error?: string;
12
+ };
13
+
14
+ type PackFetchConfig = Pick<ProjectConfig, 'tokenPath' | 'tokenSourceMode' | 'cssTokenPath'>;
15
+
16
+ const PACK_PORTS = [4000, 3000, 5173];
17
+ const PACK_PATHS = ['api/figma/scan-components'];
18
+ const SUPPORTED_SCHEMA_VERSION = 1;
19
+ const SUPPORTED_COMPONENT_DEFS_MAJOR = 1;
20
+
21
+ let pendingFetches: Record<string, (result: PackFetchResult) => void> = {};
22
+ let fetchCounter = 0;
23
+
24
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
25
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
26
+ }
27
+
28
+ function getComponentDefsPayload(raw: unknown): Record<string, unknown> | null {
29
+ if (!isPlainObject(raw)) return null;
30
+
31
+ // Direct component-definitions payload
32
+ if (Array.isArray((raw as any).components)) {
33
+ return raw;
34
+ }
35
+
36
+ // Pack envelope payload: { components: { ...componentDefs } }
37
+ const nested = (raw as any).components;
38
+ if (isPlainObject(nested) && Array.isArray((nested as any).components)) {
39
+ return nested;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ function parseMajor(version: unknown): number | null {
45
+ if (typeof version !== 'string') return null;
46
+ const match = version.trim().match(/^(\d+)(?:\.\d+){0,2}$/);
47
+ if (!match) return null;
48
+ const major = parseInt(match[1], 10);
49
+ return Number.isFinite(major) ? major : null;
50
+ }
51
+
52
+ function validateComponentDefsContract(raw: unknown): { ok: true } | { ok: false; error: string } {
53
+ const defs = getComponentDefsPayload(raw);
54
+ if (!defs) return { ok: false, error: 'invalid-pack' };
55
+
56
+ const schemaVersion = (defs as any).schemaVersion;
57
+ if (typeof schemaVersion === 'number') {
58
+ if (schemaVersion !== SUPPORTED_SCHEMA_VERSION) {
59
+ return { ok: false, error: 'incompatible-schema-version' };
60
+ }
61
+ return { ok: true };
62
+ }
63
+
64
+ // Backward compatibility with older payloads that only had `version`.
65
+ const major = parseMajor((defs as any).version);
66
+ if (major != null && major !== SUPPORTED_COMPONENT_DEFS_MAJOR) {
67
+ return { ok: false, error: 'incompatible-pack-version' };
68
+ }
69
+ return { ok: true };
70
+ }
71
+
72
+ function buildPackCandidates(config?: PackFetchConfig): string[] {
73
+ const urls: string[] = [];
74
+ for (let i = 0; i < PACK_PORTS.length; i++) {
75
+ const port = PACK_PORTS[i];
76
+ for (let j = 0; j < PACK_PATHS.length; j++) {
77
+ const path = PACK_PATHS[j];
78
+ if (!path) continue;
79
+ const baseUrl = 'http://localhost:' + port + '/' + path;
80
+ const query: string[] = [];
81
+ const requestedMode = config?.tokenSourceMode;
82
+ if (requestedMode === 'auto' || requestedMode === 'css' || requestedMode === 'dtcg') {
83
+ query.push('tokenSourceMode=' + encodeURIComponent(requestedMode));
84
+ }
85
+ const cssTokenPath = (config?.cssTokenPath || '').trim();
86
+ if (cssTokenPath) {
87
+ query.push('cssTokenPath=' + encodeURIComponent(cssTokenPath));
88
+ }
89
+ const dtcgTokenPath = (config?.tokenPath || '').trim();
90
+ if (dtcgTokenPath) {
91
+ query.push('dtcgTokenPath=' + encodeURIComponent(dtcgTokenPath));
92
+ }
93
+ const urlString = query.length ? (baseUrl + '?' + query.join('&')) : baseUrl;
94
+ urls.push(urlString);
95
+ }
96
+ }
97
+ return urls;
98
+ }
99
+
100
+ export async function fetchPackFromUI(config?: PackFetchConfig): Promise<PackFetchResult> {
101
+ await waitForUIReady();
102
+
103
+ return new Promise<PackFetchResult>(function (resolve) {
104
+ const requestId = 'pack-' + ++fetchCounter;
105
+ pendingFetches[requestId] = resolve;
106
+ figma.ui.postMessage({
107
+ type: 'fetch-pack',
108
+ requestId: requestId,
109
+ candidates: buildPackCandidates(config),
110
+ });
111
+ setTimeout(function () {
112
+ if (pendingFetches[requestId]) {
113
+ delete pendingFetches[requestId];
114
+ resolve({ requestId: requestId, data: null, error: 'timeout' });
115
+ }
116
+ }, 30000);
117
+ });
118
+ }
119
+
120
+ export function handlePackResult(msg: PackFetchResult): void {
121
+ const cb = pendingFetches[msg.requestId];
122
+ if (cb) {
123
+ delete pendingFetches[msg.requestId];
124
+ cb(msg);
125
+ }
126
+ }
127
+
128
+ export async function refreshPack(config?: PackFetchConfig): Promise<{ success: boolean; pack: Pack | null; source?: string; error?: string }> {
129
+ const result = await fetchPackFromUI(config);
130
+ if (!result || !result.data) {
131
+ debug('Pack fetch failed', { error: result ? result.error : 'no-result' });
132
+ return { success: false, pack: null, error: result ? result.error : 'no-result' };
133
+ }
134
+
135
+ const contract = validateComponentDefsContract(result.data);
136
+ if (!contract.ok) {
137
+ debug('Pack contract validation failed', { error: contract.error });
138
+ return { success: false, pack: null, error: contract.error };
139
+ }
140
+
141
+ const pack = normalizePack(result.data, result.source || 'pack');
142
+ if (!pack) {
143
+ debug('Pack data invalid');
144
+ return { success: false, pack: null, error: 'invalid-pack' };
145
+ }
146
+
147
+ return { success: true, pack: pack, source: result.source };
148
+ }
package/src/packs.ts ADDED
@@ -0,0 +1,126 @@
1
+ import { COMPONENT_DEFS } from './tokens';
2
+ import type { ComponentDefs } from './tokens';
3
+ import { createEmptyScannedTokenMap, type ScannedTokenMap } from './token-source';
4
+
5
+ export type StoryDefinition = {
6
+ id: string;
7
+ name: string;
8
+ layoutClasses?: string[] | string;
9
+ jsxTree?: any;
10
+ instances?: any[];
11
+ tags?: string[];
12
+ };
13
+
14
+ export type Pack = {
15
+ id: string;
16
+ name: string;
17
+ version?: string;
18
+ components: ComponentDefs;
19
+ tokens?: ScannedTokenMap;
20
+ stories?: StoryDefinition[];
21
+ tags?: string[];
22
+ };
23
+
24
+ let ACTIVE_PACK: Pack | null = null;
25
+
26
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
27
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
28
+ }
29
+
30
+ function normalizeComponentDefs(raw: any): ComponentDefs {
31
+ const out: ComponentDefs = {
32
+ schemaVersion: undefined,
33
+ version: undefined,
34
+ generatedAt: undefined,
35
+ components: [],
36
+ spacingScale: {},
37
+ colorTokens: [],
38
+ paletteTokens: {},
39
+ iconRegistry: {},
40
+ styleMap: {},
41
+ };
42
+ if (!raw || !isPlainObject(raw)) return out;
43
+ if (typeof raw.schemaVersion === 'number' && Number.isFinite(raw.schemaVersion)) {
44
+ out.schemaVersion = raw.schemaVersion;
45
+ }
46
+ if (typeof raw.version === 'string') out.version = raw.version;
47
+ if (typeof raw.generatedAt === 'string') out.generatedAt = raw.generatedAt;
48
+ if (Array.isArray(raw.components)) out.components = raw.components;
49
+ if (isPlainObject(raw.spacingScale)) out.spacingScale = raw.spacingScale as Record<string, any>;
50
+ if (Array.isArray(raw.colorTokens)) out.colorTokens = raw.colorTokens;
51
+ if (isPlainObject(raw.paletteTokens)) out.paletteTokens = raw.paletteTokens as Record<string, string>;
52
+ if (isPlainObject(raw.iconRegistry)) out.iconRegistry = raw.iconRegistry as Record<string, any>;
53
+ if (isPlainObject(raw.styleMap)) out.styleMap = raw.styleMap as Record<string, any>;
54
+ return out;
55
+ }
56
+
57
+ function normalizeTokenMap(raw: any): ScannedTokenMap | undefined {
58
+ if (!raw || !isPlainObject(raw)) return undefined;
59
+ const mode = raw.mode === 'css' || raw.mode === 'dtcg' || raw.mode === 'embedded'
60
+ ? raw.mode
61
+ : 'embedded';
62
+ const requestedMode = raw.requestedMode === 'css' || raw.requestedMode === 'dtcg' || raw.requestedMode === 'auto'
63
+ ? raw.requestedMode
64
+ : undefined;
65
+ const out = createEmptyScannedTokenMap(
66
+ mode,
67
+ typeof raw.source === 'string' && raw.source ? raw.source : 'embedded:tokens.ts',
68
+ requestedMode
69
+ );
70
+ if (isPlainObject(raw.colors)) out.colors = raw.colors as Record<string, string>;
71
+ if (isPlainObject(raw.radius)) out.radius = raw.radius as Record<string, number>;
72
+ if (isPlainObject(raw.fonts)) out.fonts = raw.fonts as Record<string, string>;
73
+ if (isPlainObject(raw.spacing)) out.spacing = raw.spacing as Record<string, number>;
74
+ if (isPlainObject(raw.fontSize)) out.fontSize = raw.fontSize as Record<string, number>;
75
+ if (isPlainObject(raw.shadows)) out.shadows = raw.shadows as Record<string, string>;
76
+ if (isPlainObject(raw.themes)) out.themes = raw.themes as Record<string, any>;
77
+ return out;
78
+ }
79
+
80
+ export function normalizePack(raw: any, fallbackId: string): Pack | null {
81
+ if (!raw) return null;
82
+
83
+ let componentDefs: any = null;
84
+ if (raw.components && Array.isArray(raw.components)) {
85
+ componentDefs = raw;
86
+ } else if (raw.components && isPlainObject(raw.components) && Array.isArray(raw.components.components)) {
87
+ componentDefs = raw.components;
88
+ }
89
+
90
+ if (!componentDefs) return null;
91
+
92
+ const packId = typeof raw.id === 'string' && raw.id ? raw.id : fallbackId;
93
+ const packName = typeof raw.name === 'string' && raw.name ? raw.name : packId;
94
+
95
+ return {
96
+ id: packId,
97
+ name: packName,
98
+ version: typeof raw.version === 'string' ? raw.version : undefined,
99
+ components: normalizeComponentDefs(componentDefs),
100
+ tokens: normalizeTokenMap((raw as any).tokens),
101
+ stories: Array.isArray(raw.stories) ? raw.stories : undefined,
102
+ tags: Array.isArray(raw.tags) ? raw.tags : undefined,
103
+ };
104
+ }
105
+
106
+ export function applyPack(pack: Pack): void {
107
+ ACTIVE_PACK = pack;
108
+ const defs = normalizeComponentDefs(pack.components);
109
+ COMPONENT_DEFS.components = defs.components;
110
+ COMPONENT_DEFS.schemaVersion = defs.schemaVersion;
111
+ COMPONENT_DEFS.version = defs.version;
112
+ COMPONENT_DEFS.generatedAt = defs.generatedAt;
113
+ COMPONENT_DEFS.spacingScale = defs.spacingScale;
114
+ COMPONENT_DEFS.colorTokens = defs.colorTokens;
115
+ COMPONENT_DEFS.paletteTokens = defs.paletteTokens;
116
+ COMPONENT_DEFS.iconRegistry = defs.iconRegistry;
117
+ COMPONENT_DEFS.styleMap = defs.styleMap;
118
+ }
119
+
120
+ export function getActivePack(): Pack | null {
121
+ return ACTIVE_PACK;
122
+ }
123
+
124
+ export function getActiveStories(): StoryDefinition[] {
125
+ return ACTIVE_PACK && ACTIVE_PACK.stories ? ACTIVE_PACK.stories : [];
126
+ }
@@ -0,0 +1,84 @@
1
+ import type { Transform2x3 } from './transform-math';
2
+
3
+ export type RadialAnchor = {
4
+ x: number;
5
+ y: number;
6
+ };
7
+
8
+ export function parseRadialAnchorFromUtility(utility: string): RadialAnchor | null {
9
+ const match = utility.match(/^bg-radial-\[at_(.+)\]$/);
10
+ if (!match) return null;
11
+ const token = match[1].trim();
12
+ if (!token) return null;
13
+
14
+ const keywordMap: Record<string, RadialAnchor> = {
15
+ center: { x: 0.5, y: 0.5 },
16
+ top: { x: 0.5, y: 0 },
17
+ bottom: { x: 0.5, y: 1 },
18
+ left: { x: 0, y: 0.5 },
19
+ right: { x: 1, y: 0.5 },
20
+ top_left: { x: 0, y: 0 },
21
+ top_right: { x: 1, y: 0 },
22
+ bottom_left: { x: 0, y: 1 },
23
+ bottom_right: { x: 1, y: 1 },
24
+ };
25
+
26
+ const keyword = keywordMap[token];
27
+ if (keyword) return keyword;
28
+
29
+ const parts = token.split('_');
30
+ if (parts.length !== 2) return null;
31
+ const x = parsePercent(parts[0]);
32
+ const y = parsePercent(parts[1]);
33
+ if (x == null || y == null) return null;
34
+ return { x, y };
35
+ }
36
+
37
+ function parsePercent(value: string): number | null {
38
+ const match = value.match(/^(-?\d+(?:\.\d+)?)%$/);
39
+ if (!match) return null;
40
+ const pct = parseFloat(match[1]);
41
+ if (!Number.isFinite(pct)) return null;
42
+ return pct / 100;
43
+ }
44
+
45
+ export function radialGradientTransformFromAnchor(
46
+ anchor: RadialAnchor = { x: 0.5, y: 0.5 },
47
+ ): Transform2x3 {
48
+ // CSS `radial-gradient(...)` defaults to `ellipse farthest-corner at center`.
49
+ // In normalized box space, that is a circle whose radius is the max distance
50
+ // from anchor to any corner.
51
+ const corners: Array<{ x: number; y: number }> = [
52
+ { x: 0, y: 0 },
53
+ { x: 1, y: 0 },
54
+ { x: 0, y: 1 },
55
+ { x: 1, y: 1 },
56
+ ];
57
+ let farthestDistance = 0;
58
+ for (const corner of corners) {
59
+ const dx = corner.x - anchor.x;
60
+ const dy = corner.y - anchor.y;
61
+ const dist = Math.sqrt((dx * dx) + (dy * dy));
62
+ if (dist > farthestDistance) farthestDistance = dist;
63
+ }
64
+ if (!Number.isFinite(farthestDistance) || farthestDistance <= 0) {
65
+ farthestDistance = Math.SQRT2 / 2;
66
+ }
67
+
68
+ // Figma `gradientTransform` is inverse-mapped for sampling, but radial paints
69
+ // use a half-unit basis for the default radius in local gradient space.
70
+ // For a target center (cx, cy) and radius r in normalized object coordinates:
71
+ // u = (x - cx) / (2r)
72
+ // v = (y - cy) / (2r)
73
+ // represented as:
74
+ // [u] [1/(2r) 0 0.5 - cx/(2r)] [x]
75
+ // [v] = [0 1/(2r) 0.5 - cy/(2r)] [y]
76
+ // [1]
77
+ const scale = 0.5 / farthestDistance;
78
+ const tx = 0.5 - (scale * anchor.x);
79
+ const ty = 0.5 - (scale * anchor.y);
80
+ return [
81
+ [scale, 0, tx],
82
+ [0, scale, ty],
83
+ ];
84
+ }
@@ -0,0 +1,138 @@
1
+ import type { RGB } from './colors';
2
+ import { splitClassName } from './node-ir';
3
+ import { getBaseClass } from './width-solver';
4
+
5
+ export type RenderContext = {
6
+ maxWidth?: number;
7
+ textAlign?: 'LEFT' | 'CENTER' | 'RIGHT';
8
+ textFontSize?: number;
9
+ textBold?: boolean;
10
+ /** Figma font style string (e.g. 'Medium', 'SemiBold', 'Bold') — overrides textBold when set */
11
+ textFontStyle?: string;
12
+ textLineHeight?: number;
13
+ parentLayout?: 'HORIZONTAL' | 'VERTICAL';
14
+ textColor?: string | RGB | null;
15
+ textColorToken?: string;
16
+ parentCompoundDef?: any;
17
+ accordionOpenValue?: string | null;
18
+ accordionItemIndex?: number;
19
+ accordionItemValue?: string | null;
20
+ accordionItemIsOpen?: boolean;
21
+ /** Pre-fetched images: src path → figma imageHash */
22
+ imageMap?: Map<string, string>;
23
+ /** Text truncation: true when `truncate` or `line-clamp-N` is active */
24
+ textTruncate?: boolean;
25
+ /** Maximum lines before truncation (1 for `truncate`, N for `line-clamp-N`) */
26
+ textMaxLines?: number;
27
+ };
28
+
29
+ export const TEXT_LINE_DEFAULT_WIDTHS = ['full', 'twoThirds', 'half', 'third'];
30
+ export const TEXT_LINE_WIDTH_CLASSES: Record<string, string> = {
31
+ full: 'w-full',
32
+ half: 'w-1/2',
33
+ third: 'w-1/3',
34
+ twoThirds: 'w-2/3',
35
+ };
36
+
37
+ export function parseTextLineCount(value: unknown): number {
38
+ if (typeof value === 'number' && Number.isFinite(value)) return Math.max(1, Math.floor(value));
39
+ if (typeof value === 'string') {
40
+ const parsed = parseInt(value, 10);
41
+ if (Number.isFinite(parsed)) return Math.max(1, parsed);
42
+ }
43
+ return 3;
44
+ }
45
+
46
+ export function parseWidthTokenList(value: unknown): string[] {
47
+ const sanitize = (raw: unknown[]): string[] => {
48
+ const out: string[] = [];
49
+ for (const entry of raw) {
50
+ if (typeof entry !== 'string') continue;
51
+ const key = entry.trim();
52
+ if (TEXT_LINE_WIDTH_CLASSES[key]) out.push(key);
53
+ }
54
+ return out.length > 0 ? out : TEXT_LINE_DEFAULT_WIDTHS.slice();
55
+ };
56
+
57
+ if (Array.isArray(value)) return sanitize(value);
58
+ if (typeof value === 'string') {
59
+ const trimmed = value.trim();
60
+ if (trimmed.startsWith('[')) {
61
+ try {
62
+ const parsed = JSON.parse(trimmed);
63
+ if (Array.isArray(parsed)) return sanitize(parsed);
64
+ } catch (_err) {
65
+ // ignore parse errors
66
+ }
67
+ }
68
+ const split = trimmed.split(',').map(part => part.trim()).filter(Boolean);
69
+ if (split.length > 0) return sanitize(split);
70
+ }
71
+ return TEXT_LINE_DEFAULT_WIDTHS.slice();
72
+ }
73
+
74
+ export function detectWidthTokenList(value: unknown): string[] {
75
+ const sanitize = (raw: unknown[]): string[] => {
76
+ const out: string[] = [];
77
+ for (const entry of raw) {
78
+ if (typeof entry !== 'string') continue;
79
+ const key = entry.trim();
80
+ if (TEXT_LINE_WIDTH_CLASSES[key]) out.push(key);
81
+ }
82
+ return out;
83
+ };
84
+
85
+ if (Array.isArray(value)) return sanitize(value);
86
+ if (typeof value === 'string') {
87
+ const trimmed = value.trim();
88
+ if (trimmed.startsWith('[')) {
89
+ try {
90
+ const parsed = JSON.parse(trimmed);
91
+ if (Array.isArray(parsed)) return sanitize(parsed);
92
+ } catch (_err) {
93
+ // ignore parse errors
94
+ }
95
+ }
96
+ const split = trimmed.split(',').map(part => part.trim()).filter(Boolean);
97
+ if (split.length > 0) return sanitize(split);
98
+ }
99
+ return [];
100
+ }
101
+
102
+ export function hasWidthHintInClasses(classes: string[]): boolean {
103
+ for (const cls of classes) {
104
+ const base = getBaseClass(cls) || cls.split(':').pop() || '';
105
+ if (base === 'w-full') return true;
106
+ if (/^w-\d+\/\d+$/.test(base)) return true;
107
+ }
108
+ return false;
109
+ }
110
+
111
+ export function propsContainWidthHint(props: Record<string, any> | undefined): boolean {
112
+ if (!props) return false;
113
+ if (props.className && hasWidthHintInClasses(splitClassName(props.className))) return true;
114
+ for (const key in props) {
115
+ const value = props[key];
116
+ if (typeof value === 'string') {
117
+ if (value.indexOf('w-') !== -1 && hasWidthHintInClasses(splitClassName(value))) return true;
118
+ if (detectWidthTokenList(value).length > 0) return true;
119
+ } else if (Array.isArray(value)) {
120
+ if (detectWidthTokenList(value).length > 0) return true;
121
+ }
122
+ }
123
+ return false;
124
+ }
125
+
126
+ export function hasExplicitHeight(classes: string[] | undefined): boolean {
127
+ if (!classes) return false;
128
+ for (const cls of classes) {
129
+ const base = getBaseClass(cls);
130
+ if (!base) continue;
131
+ if (base.startsWith('h-')) return true;
132
+ if (base.startsWith('min-h-')) return true;
133
+ if (base.startsWith('max-h-')) return true;
134
+ if (base.startsWith('size-')) return true;
135
+ if (base.startsWith('aspect-')) return true;
136
+ }
137
+ return false;
138
+ }
@@ -0,0 +1,139 @@
1
+ import { parseUtilityClass } from './utility-resolver';
2
+
3
+ export type BreakpointInfo = {
4
+ name: string;
5
+ minWidth: number;
6
+ classes: string[];
7
+ changes?: PropertyChange[];
8
+ };
9
+
10
+ export type PropertyChange = {
11
+ property: string;
12
+ fromValue: string;
13
+ toValue: string;
14
+ };
15
+
16
+ const BREAKPOINTS: Record<string, number> = {
17
+ sm: 640,
18
+ md: 768,
19
+ lg: 1024,
20
+ xl: 1280,
21
+ '2xl': 1536,
22
+ };
23
+
24
+ const BREAKPOINT_ORDER = ['sm', 'md', 'lg', 'xl', '2xl'];
25
+
26
+ function isResponsiveVariant(variant: string): boolean {
27
+ return BREAKPOINTS[variant] != null;
28
+ }
29
+
30
+ function normalizeUtility(atom: ReturnType<typeof parseUtilityClass>): string | null {
31
+ if (!atom.utility) return null;
32
+ return atom.important ? '!' + atom.utility : atom.utility;
33
+ }
34
+
35
+ function splitResponsiveClass(cls: string): { bucket: string; utility: string | null } | null {
36
+ const atom = parseUtilityClass(cls);
37
+ const utility = normalizeUtility(atom);
38
+ if (!utility) return null;
39
+
40
+ const responsiveVariants: string[] = [];
41
+ const remainingVariants: string[] = [];
42
+ for (let i = 0; i < atom.variants.length; i++) {
43
+ const v = atom.variants[i];
44
+ if (isResponsiveVariant(v)) {
45
+ responsiveVariants.push(v);
46
+ } else {
47
+ remainingVariants.push(v);
48
+ }
49
+ }
50
+
51
+ if (responsiveVariants.length === 0) {
52
+ if (remainingVariants.length > 0) return null;
53
+ return { bucket: 'base', utility: utility };
54
+ }
55
+
56
+ if (remainingVariants.length > 0) return null;
57
+ const bucket = responsiveVariants[responsiveVariants.length - 1];
58
+ return { bucket: bucket, utility: utility };
59
+ }
60
+
61
+ function buildResponsiveBuckets(classes: string[]): Record<string, string[]> {
62
+ const buckets: Record<string, string[]> = { base: [] };
63
+ for (let i = 0; i < BREAKPOINT_ORDER.length; i++) {
64
+ buckets[BREAKPOINT_ORDER[i]] = [];
65
+ }
66
+
67
+ for (let i = 0; i < classes.length; i++) {
68
+ const entry = splitResponsiveClass(classes[i]);
69
+ if (!entry || !entry.utility) continue;
70
+ const bucketList = buckets[entry.bucket];
71
+ if (bucketList) {
72
+ bucketList.push(entry.utility);
73
+ } else {
74
+ buckets[entry.bucket] = [entry.utility];
75
+ }
76
+ }
77
+
78
+ return buckets;
79
+ }
80
+
81
+ export function extractBreakpointsFromClasses(classes: string[]): BreakpointInfo[] {
82
+ const buckets = buildResponsiveBuckets(classes);
83
+ const hasResponsive = BREAKPOINT_ORDER.some(bp => (buckets[bp] && buckets[bp].length > 0));
84
+ const hasBase = buckets.base && buckets.base.length > 0;
85
+ if (!hasBase && !hasResponsive) return [];
86
+
87
+ const result: BreakpointInfo[] = [];
88
+ result.push({ name: 'base', minWidth: 0, classes: buckets.base || [] });
89
+
90
+ for (let i = 0; i < BREAKPOINT_ORDER.length; i++) {
91
+ const bp = BREAKPOINT_ORDER[i];
92
+ const list = buckets[bp];
93
+ if (!list || list.length === 0) continue;
94
+ result.push({ name: bp, minWidth: BREAKPOINTS[bp], classes: list });
95
+ }
96
+
97
+ return result;
98
+ }
99
+
100
+ export function getClassesForBreakpoint(classes: string[], breakpoint: string): string[] {
101
+ const buckets = buildResponsiveBuckets(classes);
102
+ const output: string[] = [];
103
+
104
+ if (buckets.base && buckets.base.length > 0) {
105
+ for (let i = 0; i < buckets.base.length; i++) output.push(buckets.base[i]);
106
+ }
107
+
108
+ const targetIndex = BREAKPOINT_ORDER.indexOf(breakpoint);
109
+ if (breakpoint === 'base' || targetIndex === -1) {
110
+ return output;
111
+ }
112
+
113
+ for (let i = 0; i <= targetIndex; i++) {
114
+ const bp = BREAKPOINT_ORDER[i];
115
+ const list = buckets[bp];
116
+ if (!list || list.length === 0) continue;
117
+ for (let j = 0; j < list.length; j++) output.push(list[j]);
118
+ }
119
+
120
+ return output;
121
+ }
122
+
123
+ export function analyzeBreakpointChanges(_classes: string[]): PropertyChange[] {
124
+ return [];
125
+ }
126
+
127
+ export function hasSignificantResponsiveChanges(classes: string[]): boolean {
128
+ const buckets = buildResponsiveBuckets(classes);
129
+ for (let i = 0; i < BREAKPOINT_ORDER.length; i++) {
130
+ const bp = BREAKPOINT_ORDER[i];
131
+ if (buckets[bp] && buckets[bp].length > 0) return true;
132
+ }
133
+ return false;
134
+ }
135
+
136
+ export function getBreakpointLabel(name: string, minWidth: number): string {
137
+ if (name === 'base') return 'Base';
138
+ return name + ' ≥' + minWidth;
139
+ }