inkbridge 0.1.0-beta.2 → 0.1.0-beta.21

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 (178) hide show
  1. package/README.md +108 -25
  2. package/bin/inkbridge.mjs +354 -83
  3. package/code.js +40 -11802
  4. package/manifest.json +1 -0
  5. package/package.json +74 -23
  6. package/scanner/adapter-utils-regression.ts +159 -0
  7. package/scanner/aspect-percent-position-regression.ts +237 -0
  8. package/scanner/aspect-ratio-regression.ts +90 -0
  9. package/scanner/blob-placement-regression.ts +2 -2
  10. package/scanner/block-cache-regression.ts +195 -0
  11. package/scanner/bundle-size-regression.ts +50 -0
  12. package/scanner/child-sizing-matrix-regression.ts +303 -0
  13. package/scanner/cli.ts +342 -13
  14. package/scanner/component-scanner.ts +2108 -174
  15. package/scanner/component-sections-regression.ts +198 -0
  16. package/scanner/compound-classes-lookup-regression.ts +163 -0
  17. package/scanner/css-token-reader-regression.ts +7 -6
  18. package/scanner/css-token-reader.ts +152 -31
  19. package/scanner/cva-jsx-child-fallback-regression.ts +98 -0
  20. package/scanner/cva-master-icon-regression.ts +315 -0
  21. package/scanner/data-attr-prop-alias-regression.ts +129 -0
  22. package/scanner/explicit-size-root-regression.ts +102 -0
  23. package/scanner/font-family-extract-regression.ts +113 -0
  24. package/scanner/font-style-resolver-regression.ts +1 -1
  25. package/scanner/framework-adapter-shadcn-regression.ts +480 -0
  26. package/scanner/full-width-matrix-regression.ts +338 -0
  27. package/scanner/grid-cols-extraction-regression.ts +110 -0
  28. package/scanner/image-src-collector-regression.ts +204 -0
  29. package/scanner/inline-flex-regression.ts +235 -0
  30. package/scanner/input-range-regression.ts +217 -0
  31. package/scanner/instance-rendering-regression.ts +224 -0
  32. package/scanner/jsx-prop-unresolved-regression.ts +178 -0
  33. package/scanner/jsx-text-regression.ts +178 -0
  34. package/scanner/layout-alignment-regression.ts +108 -0
  35. package/scanner/layout-flex-regression.ts +90 -0
  36. package/scanner/layout-mode-regression.ts +71 -0
  37. package/scanner/layout-sizing-regression.ts +227 -0
  38. package/scanner/layout-spacing-regression.ts +135 -0
  39. package/scanner/local-const-className-regression.ts +331 -0
  40. package/scanner/percent-position-regression.ts +105 -0
  41. package/scanner/provider-cascade-regression.ts +224 -0
  42. package/scanner/provider-flatten-regression.ts +235 -0
  43. package/scanner/radial-gradient-regression.ts +1 -1
  44. package/scanner/render-prop-parser-regression.ts +161 -0
  45. package/scanner/ring-utility-regression.ts +153 -0
  46. package/scanner/sandbox-spread-regression.ts +125 -0
  47. package/scanner/selection-pressed-regression.ts +241 -0
  48. package/scanner/size-full-normalization-regression.ts +127 -0
  49. package/scanner/state-classification-regression.ts +175 -0
  50. package/scanner/story-diagnostics-regression.ts +216 -0
  51. package/scanner/story-dimensioning-regression.ts +298 -0
  52. package/scanner/story-render-strategy-regression.ts +205 -0
  53. package/scanner/stretch-to-parent-width-regression.ts +147 -0
  54. package/scanner/svg-fill-parent-regression.ts +98 -0
  55. package/scanner/svg-group-inheritance-regression.ts +166 -0
  56. package/scanner/svg-marker-inline-regression.ts +211 -0
  57. package/scanner/svg-marker-regression.ts +116 -0
  58. package/scanner/tailwind-parser.ts +46 -4
  59. package/scanner/text-resize-matrix-regression.ts +173 -0
  60. package/scanner/transform-math-regression.ts +1 -1
  61. package/scanner/types.ts +26 -2
  62. package/src/cache/frame-cache.ts +150 -0
  63. package/src/cache/index.ts +2 -0
  64. package/src/{component-defs.ts → components/component-defs.ts} +25 -10
  65. package/src/{component-gen.ts → components/component-gen.ts} +43 -116
  66. package/src/components/component-instance.ts +386 -0
  67. package/src/components/component-library.ts +44 -0
  68. package/src/components/component-lookup.ts +161 -0
  69. package/src/components/index.ts +7 -0
  70. package/src/components/scanner-types.ts +39 -0
  71. package/src/components/symbol-instance-policy.ts +312 -0
  72. package/src/design-system/block-cache.ts +130 -0
  73. package/src/design-system/component-sections.ts +107 -0
  74. package/src/design-system/cva-inference.ts +187 -0
  75. package/src/design-system/cva-master.ts +427 -0
  76. package/src/design-system/cva-utils.ts +29 -0
  77. package/src/design-system/design-system.ts +334 -0
  78. package/src/design-system/frame-stabilizers.ts +191 -0
  79. package/src/design-system/frame-utils.ts +46 -0
  80. package/src/design-system/generated-node.ts +84 -0
  81. package/src/design-system/icon-rendering.ts +229 -0
  82. package/src/design-system/index.ts +13 -0
  83. package/src/design-system/instance-rendering.ts +307 -0
  84. package/src/design-system/master-shared.ts +133 -0
  85. package/src/design-system/node-helpers.ts +237 -0
  86. package/src/design-system/node-variants.ts +196 -0
  87. package/src/design-system/non-cva-master.ts +104 -0
  88. package/src/design-system/portal-handling.ts +138 -0
  89. package/src/design-system/preview-builder.ts +738 -0
  90. package/src/{render-context.ts → design-system/render-context.ts} +32 -6
  91. package/src/design-system/render-prop-parser.ts +50 -0
  92. package/src/design-system/responsive-resolver.ts +180 -0
  93. package/src/design-system/selectable-state.ts +157 -0
  94. package/src/design-system/state-master.ts +267 -0
  95. package/src/design-system/state-utils.ts +15 -0
  96. package/src/design-system/story-builder-context.ts +40 -0
  97. package/src/design-system/story-builder.ts +1322 -0
  98. package/src/design-system/story-diagnostics.ts +80 -0
  99. package/src/design-system/story-dimensioning.ts +272 -0
  100. package/src/design-system/story-frames.ts +400 -0
  101. package/src/design-system/story-instance.ts +333 -0
  102. package/src/{story-layout.ts → design-system/story-layout.ts} +2 -2
  103. package/src/design-system/story-render-strategy.ts +150 -0
  104. package/src/design-system/story-tree-search.ts +110 -0
  105. package/src/design-system/symbol-fallback.ts +89 -0
  106. package/src/design-system/symbol-source.ts +172 -0
  107. package/src/design-system/table-helpers.ts +56 -0
  108. package/src/design-system/tag-predicates.ts +99 -0
  109. package/src/design-system/theme-context.ts +52 -0
  110. package/src/design-system/typography.ts +100 -0
  111. package/src/design-system/ui-builder.ts +2676 -0
  112. package/src/{clip-path-decorative.ts → effects/clip-path-decorative.ts} +11 -11
  113. package/src/effects/icon-builder.ts +1074 -0
  114. package/src/effects/index.ts +5 -0
  115. package/src/effects/portal-panel.ts +369 -0
  116. package/src/{radial-gradient.ts → effects/radial-gradient.ts} +1 -1
  117. package/src/framework-adapters/index.ts +47 -0
  118. package/src/framework-adapters/shadcn.ts +541 -0
  119. package/src/{github.ts → github/github.ts} +46 -21
  120. package/src/github/index.ts +1 -0
  121. package/src/layout/deferred-layout.ts +1556 -0
  122. package/src/layout/index.ts +24 -0
  123. package/src/layout/layout-parser.ts +375 -0
  124. package/src/{layout-utils.ts → layout/layout-utils.ts} +23 -17
  125. package/src/layout/parser/alignment.ts +54 -0
  126. package/src/layout/parser/flex.ts +59 -0
  127. package/src/layout/parser/index.ts +65 -0
  128. package/src/layout/parser/ir.ts +80 -0
  129. package/src/layout/parser/layout-mode.ts +57 -0
  130. package/src/layout/parser/sizing.ts +241 -0
  131. package/src/layout/parser/spacing-scale.ts +78 -0
  132. package/src/layout/parser/spacing.ts +134 -0
  133. package/src/layout/ring-utils.ts +120 -0
  134. package/src/layout/size-utils.ts +143 -0
  135. package/src/layout/text-resize-decision.ts +51 -0
  136. package/src/{width-solver.ts → layout/width-solver.ts} +168 -37
  137. package/src/main.ts +444 -162
  138. package/src/{config.ts → plugin/config.ts} +12 -12
  139. package/src/{dev-server.ts → plugin/dev-server.ts} +3 -3
  140. package/src/plugin/image-src-collector.ts +52 -0
  141. package/src/plugin/index.ts +3 -0
  142. package/src/plugin/packs/index.ts +2 -0
  143. package/src/{pack-provider.ts → plugin/packs/pack-provider.ts} +12 -12
  144. package/src/{packs.ts → plugin/packs/packs.ts} +22 -17
  145. package/src/render-engine-version.ts +2 -0
  146. package/src/tailwind/adapter-utils.ts +137 -0
  147. package/src/{class-utils.ts → tailwind/class-utils.ts} +33 -6
  148. package/src/tailwind/index.ts +8 -0
  149. package/src/tailwind/jsx-utils.ts +319 -0
  150. package/src/{node-ir.ts → tailwind/node-ir.ts} +208 -19
  151. package/src/{responsive-analyzer.ts → tailwind/responsive-analyzer.ts} +32 -2
  152. package/src/{state-analyzer.ts → tailwind/state-analyzer.ts} +71 -5
  153. package/src/{tailwind.ts → tailwind/tailwind.ts} +423 -674
  154. package/src/{utility-resolver.ts → tailwind/utility-resolver.ts} +27 -6
  155. package/src/{font-style-resolver.ts → text/font-style-resolver.ts} +0 -2
  156. package/src/text/index.ts +4 -0
  157. package/src/{inline-text.ts → text/inline-text.ts} +13 -13
  158. package/src/{text-builder.ts → text/text-builder.ts} +24 -7
  159. package/src/{text-line.ts → text/text-line.ts} +2 -2
  160. package/src/{change-detection.ts → tokens/change-detection.ts} +12 -12
  161. package/src/{color-resolver.ts → tokens/color-resolver.ts} +1 -6
  162. package/src/{colors.ts → tokens/colors.ts} +13 -6
  163. package/src/tokens/index.ts +6 -0
  164. package/src/{token-source.ts → tokens/token-source.ts} +4 -1
  165. package/src/{tokens.ts → tokens/tokens.ts} +116 -20
  166. package/src/{variables.ts → tokens/variables.ts} +447 -102
  167. package/templates/patch-tokens-route.ts +25 -6
  168. package/templates/scan-components-route.ts +26 -5
  169. package/ui.html +485 -37
  170. package/src/component-lookup.ts +0 -82
  171. package/src/design-system.ts +0 -59
  172. package/src/icon-builder.ts +0 -607
  173. package/src/layout-parser.ts +0 -667
  174. package/src/story-builder.ts +0 -1706
  175. package/src/ui-builder.ts +0 -1996
  176. /package/src/{image-cache.ts → cache/image-cache.ts} +0 -0
  177. /package/src/{blob-placement.ts → effects/blob-placement.ts} +0 -0
  178. /package/src/{transform-math.ts → tailwind/transform-math.ts} +0 -0
@@ -1,9 +1,19 @@
1
- import type { RGB } from './colors';
2
- import { splitClassName } from './node-ir';
3
- import { getBaseClass } from './width-solver';
1
+ import type { RGB } from '../tokens';
2
+ import { splitClassName, getBaseClass } from '../tailwind';
4
3
 
5
4
  export type RenderContext = {
6
5
  maxWidth?: number;
6
+ /**
7
+ * Story-level viewport width used for responsive breakpoint resolution.
8
+ * Stays constant through the entire tree (unlike `maxWidth`, which is the
9
+ * current container's content width and shrinks as we descend). CSS
10
+ * `@media (min-width: …)` queries match against the viewport, not the
11
+ * element's container — using `maxWidth` for breakpoints causes nested
12
+ * elements (e.g. icons inside an `aspect-5/3` container at 720px in a
13
+ * 1280px story) to resolve at a smaller breakpoint than the viewport
14
+ * actually is, so `lg:size-16` etc. silently fail to apply.
15
+ */
16
+ viewportWidth?: number;
7
17
  textAlign?: 'LEFT' | 'CENTER' | 'RIGHT';
8
18
  textFontSize?: number;
9
19
  textBold?: boolean;
@@ -13,11 +23,27 @@ export type RenderContext = {
13
23
  parentLayout?: 'HORIZONTAL' | 'VERTICAL';
14
24
  textColor?: string | RGB | null;
15
25
  textColorToken?: string;
16
- parentCompoundDef?: any;
26
+ parentCompoundDef?: unknown;
17
27
  accordionOpenValue?: string | null;
18
28
  accordionItemIndex?: number;
19
29
  accordionItemValue?: string | null;
20
30
  accordionItemIsOpen?: boolean;
31
+ tabsSelectedValue?: string | null;
32
+ selectSelectedValue?: string | null;
33
+ selectSelectedLabel?: string | null;
34
+ selectItemValue?: string | null;
35
+ selectItemSelected?: boolean;
36
+ /** True when the enclosing <Select> has open=true or defaultOpen=true. */
37
+ selectIsOpen?: boolean;
38
+ /**
39
+ * Value of the first SelectItem when the Select is open without a selected
40
+ * value. Radix highlights the first item by default in that case, so we
41
+ * render the same highlight in Figma previews.
42
+ */
43
+ selectHighlightedValue?: string | null;
44
+ radioGroupSelectedValue?: string | null;
45
+ radioItemValue?: string | null;
46
+ radioItemChecked?: boolean;
21
47
  /** Pre-fetched images: src path → figma imageHash */
22
48
  imageMap?: Map<string, string>;
23
49
  /** Text truncation: true when `truncate` or `line-clamp-N` is active */
@@ -108,9 +134,9 @@ export function hasWidthHintInClasses(classes: string[]): boolean {
108
134
  return false;
109
135
  }
110
136
 
111
- export function propsContainWidthHint(props: Record<string, any> | undefined): boolean {
137
+ export function propsContainWidthHint(props: Record<string, unknown> | undefined): boolean {
112
138
  if (!props) return false;
113
- if (props.className && hasWidthHintInClasses(splitClassName(props.className))) return true;
139
+ if (typeof props.className === 'string' && hasWidthHintInClasses(splitClassName(props.className))) return true;
114
140
  for (const key in props) {
115
141
  const value = props[key];
116
142
  if (typeof value === 'string') {
@@ -0,0 +1,50 @@
1
+ import type { ComponentInstance } from '../components';
2
+
3
+ /**
4
+ * Parses a Storybook `render={...}` prop expressed as a string-literal JSX
5
+ * snippet (e.g. `'<Button variant="primary">Click</Button>'`) into a synthetic
6
+ * ComponentInstance that the renderer can treat like a real scanner-emitted
7
+ * instance.
8
+ *
9
+ * Used when a story declares its render via a stringified JSX literal instead
10
+ * of a real React render function — the scanner can't expand the literal into
11
+ * a JsxTree, so this parser reconstructs the minimum shape (tagName, props,
12
+ * children-as-text) required for the plugin's instance rendering path.
13
+ *
14
+ * Constraints / non-goals:
15
+ * - Single root element only. Multi-root fragments / arrays return null.
16
+ * - Attributes must be double-quoted strings. Boolean shorthand (`disabled`)
17
+ * is ignored. JSX expressions (`prop={value}`) are ignored.
18
+ * - Self-closing tags (`<Foo />`) are NOT supported — the closing tag pattern
19
+ * is required so the parser stays strict.
20
+ * - Returns null for any input it can't handle, never throws.
21
+ */
22
+ export function parseRenderPropToSyntheticInstance(renderProp: unknown): ComponentInstance | null {
23
+ if (typeof renderProp !== 'string') return null;
24
+ const input = renderProp.trim();
25
+ if (!input) return null;
26
+
27
+ const match = input.match(/^<([A-Za-z0-9_.-]+)([^>]*)>([\s\S]*?)<\/\1>$/);
28
+ if (!match) return null;
29
+
30
+ const tagName = String(match[1] || '').trim();
31
+ if (!tagName) return null;
32
+ const rawAttrs = String(match[2] || '');
33
+ const rawChildren = String(match[3] || '').trim();
34
+ const props: Record<string, string> = {};
35
+
36
+ const attrRegex = /([A-Za-z0-9_:-]+)="([^"]*)"/g;
37
+ let attrMatch: RegExpExecArray | null = null;
38
+ while ((attrMatch = attrRegex.exec(rawAttrs)) !== null) {
39
+ const key = String(attrMatch[1] || '').trim();
40
+ if (!key) continue;
41
+ props[key] = attrMatch[2];
42
+ }
43
+
44
+ // Preserve component naming as scanned (e.g. Button) and keep text as children.
45
+ return {
46
+ componentName: tagName,
47
+ props: props,
48
+ children: rawChildren || undefined,
49
+ };
50
+ }
@@ -0,0 +1,180 @@
1
+ import { parseUtilityClass, type NodeIR } from '../tailwind';
2
+
3
+ /**
4
+ * Tailwind responsive-variant resolution. Given a NodeIR (or a flat class
5
+ * list) and a target width, strip variants that don't apply at that width
6
+ * and promote the matching ones to active utilities.
7
+ *
8
+ * Also exposes `promoteFocusVariants` — the renderer uses it to surface
9
+ * focus styles for nodes that are known to be focused (e.g. an open
10
+ * Select trigger).
11
+ *
12
+ * Pure: NodeIR-only walk, no Figma calls.
13
+ */
14
+
15
+ const RESPONSIVE_BREAKPOINT_ORDER = ['sm', 'md', 'lg', 'xl', '2xl'] as const;
16
+ const RESPONSIVE_BREAKPOINT_MIN_WIDTH: Record<typeof RESPONSIVE_BREAKPOINT_ORDER[number], number> = {
17
+ sm: 640,
18
+ md: 768,
19
+ lg: 1024,
20
+ xl: 1280,
21
+ '2xl': 1536,
22
+ };
23
+
24
+ export function resolveBreakpointIndexForWidth(width?: number): number {
25
+ if (!Number.isFinite(width) || (width as number) <= 0) return -1;
26
+ let idx = -1;
27
+ for (let i = 0; i < RESPONSIVE_BREAKPOINT_ORDER.length; i++) {
28
+ const bp = RESPONSIVE_BREAKPOINT_ORDER[i];
29
+ if ((width as number) >= RESPONSIVE_BREAKPOINT_MIN_WIDTH[bp]) {
30
+ idx = i;
31
+ }
32
+ }
33
+ return idx;
34
+ }
35
+
36
+ /**
37
+ * Promote `focus:` / `focus-visible:` variants to active base utilities by
38
+ * stripping the variant prefix. Used for elements that are known to be focused
39
+ * (e.g. an open Select's trigger) so focus-only visuals render in Figma.
40
+ */
41
+ export function promoteFocusVariants(classes: string[]): string[] {
42
+ let out: string[] | null = null;
43
+ const seen: Record<string, true> = {};
44
+ for (let i = 0; i < classes.length; i++) seen[classes[i]] = true;
45
+
46
+ for (let i = 0; i < classes.length; i++) {
47
+ const cls = classes[i];
48
+ const m = cls.match(/^(focus|focus-visible):(.+)$/);
49
+ if (!m) continue;
50
+ const base = m[2];
51
+ if (seen[base]) continue;
52
+ if (!out) out = classes.slice();
53
+ out.push(base);
54
+ seen[base] = true;
55
+ }
56
+ return out || classes;
57
+ }
58
+
59
+ function getResponsiveConflictGroup(utility: string): string | null {
60
+ if (
61
+ utility === 'flex-row' ||
62
+ utility === 'flex-row-reverse' ||
63
+ utility === 'flex-col' ||
64
+ utility === 'flex-col-reverse'
65
+ ) {
66
+ return 'layout-direction';
67
+ }
68
+
69
+ if (
70
+ utility === 'justify-start' ||
71
+ utility === 'justify-center' ||
72
+ utility === 'justify-end' ||
73
+ utility === 'justify-between' ||
74
+ utility === 'justify-around' ||
75
+ utility === 'justify-evenly'
76
+ ) {
77
+ return 'main-align';
78
+ }
79
+
80
+ if (
81
+ utility === 'items-start' ||
82
+ utility === 'items-center' ||
83
+ utility === 'items-end' ||
84
+ utility === 'items-stretch' ||
85
+ utility === 'items-baseline'
86
+ ) {
87
+ return 'cross-align';
88
+ }
89
+
90
+ if (
91
+ utility === 'text-left' ||
92
+ utility === 'text-center' ||
93
+ utility === 'text-right' ||
94
+ utility === 'text-justify'
95
+ ) {
96
+ return 'text-align';
97
+ }
98
+
99
+ return null;
100
+ }
101
+
102
+ export function resolveClassesForBreakpoint(classes: string[], breakpointIndex: number): string[] {
103
+ if (!classes || classes.length === 0) return classes || [];
104
+
105
+ const out: string[] = [];
106
+ const conflictIndex = new Map<string, number>();
107
+ for (let i = 0; i < classes.length; i++) {
108
+ const atom = parseUtilityClass(classes[i]);
109
+ if (!atom.utility) continue;
110
+
111
+ let requiredBreakpoint = -1;
112
+ const remainingVariants: string[] = [];
113
+ for (let j = 0; j < atom.variants.length; j++) {
114
+ const variant = atom.variants[j];
115
+ const responsiveIdx = RESPONSIVE_BREAKPOINT_ORDER.indexOf(variant as typeof RESPONSIVE_BREAKPOINT_ORDER[number]);
116
+ if (responsiveIdx !== -1) {
117
+ if (responsiveIdx > requiredBreakpoint) requiredBreakpoint = responsiveIdx;
118
+ } else {
119
+ remainingVariants.push(variant);
120
+ }
121
+ }
122
+
123
+ if (requiredBreakpoint !== -1 && breakpointIndex < requiredBreakpoint) {
124
+ continue;
125
+ }
126
+
127
+ const rebuilt = remainingVariants.length > 0
128
+ ? remainingVariants.join(':') + ':' + atom.utility
129
+ : atom.utility;
130
+ const finalClass = atom.important ? '!' + rebuilt : rebuilt;
131
+ const conflictGroup = getResponsiveConflictGroup(atom.utility);
132
+ if (conflictGroup) {
133
+ const variantScope = remainingVariants.join(':');
134
+ const conflictKey = `${variantScope}|${conflictGroup}`;
135
+ const existingIndex = conflictIndex.get(conflictKey);
136
+ if (existingIndex !== undefined) {
137
+ out[existingIndex] = finalClass;
138
+ } else {
139
+ conflictIndex.set(conflictKey, out.length);
140
+ out.push(finalClass);
141
+ }
142
+ continue;
143
+ }
144
+
145
+ out.push(finalClass);
146
+ }
147
+ return out;
148
+ }
149
+
150
+ export function resolveNodeResponsiveClasses(node: NodeIR, width?: number): NodeIR {
151
+ // When width is unknown, keep all responsive variants intact — we can't
152
+ // decide which ones apply. Below `sm` (width < 640) we still want to
153
+ // resolve: `resolveBreakpointIndexForWidth` returns -1 for both cases,
154
+ // but `resolveClassesForBreakpoint` with `-1` already does the right
155
+ // thing (strips every `{sm|md|lg|xl|2xl}:` variant, keeps base). Only
156
+ // early-return on actually-unknown widths so mobile previews don't
157
+ // inherit desktop-only utilities like `sm:flex-row` on `flex-col-reverse`.
158
+ if (!Number.isFinite(width) || (width as number) <= 0) return node;
159
+ const breakpointIndex = resolveBreakpointIndexForWidth(width);
160
+
161
+ if (node.kind === 'text' || node.kind === 'divider') return node;
162
+
163
+ if (node.kind === 'fragment') {
164
+ return Object.assign({}, node, {
165
+ children: node.children.map(child => resolveNodeResponsiveClasses(child, width)),
166
+ });
167
+ }
168
+
169
+ if (node.kind === 'ring') {
170
+ return Object.assign({}, node, {
171
+ classes: resolveClassesForBreakpoint(node.classes, breakpointIndex),
172
+ child: resolveNodeResponsiveClasses(node.child, width),
173
+ });
174
+ }
175
+
176
+ return Object.assign({}, node, {
177
+ classes: resolveClassesForBreakpoint(node.classes, breakpointIndex),
178
+ children: node.children.map(child => resolveNodeResponsiveClasses(child, width)),
179
+ });
180
+ }
@@ -0,0 +1,157 @@
1
+ import { isTruthyStateProp } from './state-utils';
2
+ import { isSelectItemTag } from './tag-predicates';
3
+ import type { NodeIR, NodeIRElement } from '../tailwind';
4
+ import type { RenderContext } from './render-context';
5
+
6
+ /**
7
+ * State and value resolution for the interactive component families that
8
+ * carry a "currently-selected" or "currently-active" notion: Select, Tabs,
9
+ * Accordion, RadioGroup. Used by ui-builder to decide which item should
10
+ * render in its open / checked / active visual.
11
+ *
12
+ * Pure: only walks the NodeIR tree and reads RenderContext / props.
13
+ */
14
+
15
+ export function normalizeSelectableValue(value: unknown): string | null {
16
+ if (typeof value !== 'string') return null;
17
+ const trimmed = value.trim();
18
+ if (!trimmed || trimmed === 'undefined' || trimmed === 'null' || trimmed === 'defaultValue' || trimmed === 'value') {
19
+ return null;
20
+ }
21
+ return trimmed;
22
+ }
23
+
24
+ export function normalizeAccordionDefaultValue(value: unknown): string | null {
25
+ if (typeof value !== 'string') return null;
26
+ const trimmed = value.trim();
27
+ if (!trimmed || trimmed === 'defaultValue' || trimmed === 'undefined' || trimmed === 'null') {
28
+ return null;
29
+ }
30
+ return trimmed;
31
+ }
32
+
33
+ export function resolveAccordionItemValue(value: unknown, itemIndex?: number): string | null {
34
+ if (typeof value === 'string') {
35
+ const trimmed = value.trim();
36
+ if (trimmed) {
37
+ if (trimmed.includes('${i}')) {
38
+ return Number.isFinite(itemIndex) ? `item-${itemIndex}` : null;
39
+ }
40
+ return trimmed;
41
+ }
42
+ }
43
+ return Number.isFinite(itemIndex) ? `item-${itemIndex}` : null;
44
+ }
45
+
46
+ export function resolveRadioItemChecked(node: NodeIRElement, context: RenderContext): boolean | null {
47
+ const props = node.props || {};
48
+ if (
49
+ isTruthyStateProp(props.checked) ||
50
+ isTruthyStateProp(props.defaultChecked) ||
51
+ isTruthyStateProp(props['aria-checked']) ||
52
+ String(props['data-state'] || '').trim().toLowerCase() === 'checked'
53
+ ) {
54
+ return true;
55
+ }
56
+ const selected = normalizeSelectableValue(context.radioGroupSelectedValue);
57
+ const value = normalizeSelectableValue(props.value);
58
+ if (selected == null || value == null) return null;
59
+ return selected === value;
60
+ }
61
+
62
+ export function resolveTabsNodeActive(node: NodeIRElement, context: RenderContext): boolean | null {
63
+ const props = node.props || {};
64
+ const selected = normalizeSelectableValue(context.tabsSelectedValue);
65
+ const value = normalizeSelectableValue(props.value);
66
+ if (selected == null || value == null) return null;
67
+ return selected === value;
68
+ }
69
+
70
+ export function resolveSelectItemSelected(node: NodeIRElement, context: RenderContext): boolean | null {
71
+ const props = node.props || {};
72
+ if (
73
+ isTruthyStateProp(props.selected) ||
74
+ isTruthyStateProp(props['aria-selected']) ||
75
+ String(props['data-state'] || '').trim().toLowerCase() === 'checked'
76
+ ) {
77
+ return true;
78
+ }
79
+ const selected = normalizeSelectableValue(context.selectSelectedValue);
80
+ const value = normalizeSelectableValue(props.value);
81
+ if (selected == null || value == null) return null;
82
+ return selected === value;
83
+ }
84
+
85
+ /**
86
+ * Recursively collect visible text content from a resolved NodeIR tree.
87
+ * Skips hidden / sr-only nodes and dividers.
88
+ */
89
+ export function collectTextContent(node: NodeIR): string {
90
+ const parts: string[] = [];
91
+ const walk = (current: NodeIR): void => {
92
+ if (current.kind === 'text') {
93
+ parts.push(current.text);
94
+ return;
95
+ }
96
+ if (current.kind === 'fragment') {
97
+ for (const child of current.children) {
98
+ walk(child);
99
+ }
100
+ return;
101
+ }
102
+ if (current.kind === 'divider') {
103
+ return;
104
+ }
105
+ if (current.kind === 'ring') {
106
+ walk(current.child);
107
+ return;
108
+ }
109
+ if (current.classes.includes('hidden') || current.classes.includes('sr-only')) {
110
+ return;
111
+ }
112
+ for (const child of current.children || []) {
113
+ walk(child);
114
+ }
115
+ };
116
+ walk(node);
117
+ return parts.join(' ').replace(/\s+/g, ' ').trim();
118
+ }
119
+
120
+ export function findSelectedSelectLabel(node: NodeIR, selectedValue: string): string | null {
121
+ if (node.kind !== 'element' && node.kind !== 'component') return null;
122
+ if (isSelectItemTag(node.tagName)) {
123
+ const itemValue = normalizeSelectableValue((node.props || {}).value);
124
+ if (itemValue != null && itemValue === selectedValue) {
125
+ const label = collectTextContent(node).trim();
126
+ return label || null;
127
+ }
128
+ }
129
+ for (const child of node.children || []) {
130
+ const found = findSelectedSelectLabel(child, selectedValue);
131
+ if (found) return found;
132
+ }
133
+ return null;
134
+ }
135
+
136
+ export function findFirstSelectItemValue(node: NodeIR): string | null {
137
+ if (node.kind !== 'element' && node.kind !== 'component') return null;
138
+ if (isSelectItemTag(node.tagName)) {
139
+ const itemValue = normalizeSelectableValue((node.props || {}).value);
140
+ if (itemValue != null) return itemValue;
141
+ }
142
+ for (const child of node.children || []) {
143
+ const found = findFirstSelectItemValue(child);
144
+ if (found) return found;
145
+ }
146
+ return null;
147
+ }
148
+
149
+ export function prettySelectValueLabel(value: string): string {
150
+ const text = String(value || '').trim();
151
+ if (!text) return text;
152
+ if (text.includes('-') || text.includes('_')) {
153
+ const normalized = text.replace(/[-_]+/g, ' ');
154
+ return normalized.replace(/\b\w/g, (m) => m.toUpperCase());
155
+ }
156
+ return text.charAt(0).toUpperCase() + text.slice(1);
157
+ }