inkbridge 0.1.0-beta.21 → 0.1.0-beta.23

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 (69) hide show
  1. package/README.md +29 -0
  2. package/code.js +15 -15
  3. package/manifest.json +1 -2
  4. package/package.json +40 -22
  5. package/scanner/border-dash-pattern-regression.ts +163 -0
  6. package/scanner/child-sizing-matrix-regression.ts +9 -0
  7. package/scanner/cli.ts +21 -5
  8. package/scanner/component-scanner.ts +1333 -77
  9. package/scanner/conditional-map-branch-regression.ts +180 -0
  10. package/scanner/css-token-reader.ts +66 -5
  11. package/scanner/dialog-content-gate-regression.ts +195 -0
  12. package/scanner/expression-evaluator-regression.ts +432 -0
  13. package/scanner/framework-adapter-shadcn-regression.ts +157 -1
  14. package/scanner/hidden-check-drift-regression.ts +125 -0
  15. package/scanner/horizontal-text-shrink-regression.ts +230 -0
  16. package/scanner/imported-array-map-regression.ts +195 -0
  17. package/scanner/inline-flex-regression.ts +5 -0
  18. package/scanner/intrinsic-sizing-regression.ts +333 -0
  19. package/scanner/portal-class-strip-regression.ts +109 -0
  20. package/scanner/responsive-hidden-inline-regression.ts +226 -0
  21. package/scanner/responsive-opt-in-regression.ts +212 -0
  22. package/scanner/select-root-flatten-regression.ts +314 -0
  23. package/scanner/space-between-single-child-regression.ts +163 -0
  24. package/scanner/story-args-resolution-regression.ts +311 -0
  25. package/scanner/story-dimensioning-regression.ts +76 -1
  26. package/scanner/style-map.ts +57 -0
  27. package/scanner/table-column-alignment-regression.ts +355 -0
  28. package/scanner/ternary-fragment-branch-regression.ts +196 -0
  29. package/scanner/text-truncate-regression.ts +481 -0
  30. package/scanner/types.ts +13 -0
  31. package/src/components/component-gen.ts +21 -38
  32. package/src/design-system/cva-master.ts +11 -18
  33. package/src/design-system/design-system.ts +36 -7
  34. package/src/design-system/frame-stabilizers.ts +109 -12
  35. package/src/design-system/preview-builder.ts +38 -0
  36. package/src/design-system/selectable-state.ts +8 -1
  37. package/src/design-system/story-builder.ts +62 -32
  38. package/src/design-system/story-dimensioning.ts +14 -3
  39. package/src/design-system/tag-predicates.ts +8 -0
  40. package/src/design-system/typography.ts +26 -0
  41. package/src/design-system/ui-builder.ts +188 -60
  42. package/src/effects/icon-builder.ts +8 -0
  43. package/src/framework-adapters/shadcn.ts +113 -0
  44. package/src/github/github.ts +22 -4
  45. package/src/layout/index.ts +4 -0
  46. package/src/layout/intrinsic-applier.ts +105 -0
  47. package/src/layout/intrinsic-sizing.ts +183 -0
  48. package/src/layout/layout-parser.ts +36 -0
  49. package/src/layout/parser/layout-mode.ts +14 -4
  50. package/src/layout/table-layout.ts +271 -0
  51. package/src/layout/text-truncate-pass.ts +151 -0
  52. package/src/layout/width-solver.ts +63 -17
  53. package/src/main.ts +37 -124
  54. package/src/plugin/config.ts +21 -0
  55. package/src/plugin/packs/pack-provider.ts +20 -4
  56. package/src/plugin/packs/packs.ts +14 -0
  57. package/src/render-engine-version.ts +1 -1
  58. package/src/tailwind/jsx-utils.ts +39 -0
  59. package/src/tailwind/node-ir.ts +8 -1
  60. package/src/tailwind/responsive-analyzer.ts +57 -3
  61. package/src/tailwind/tailwind.ts +344 -51
  62. package/src/text/index.ts +1 -0
  63. package/src/text/inline-text.ts +112 -12
  64. package/src/text/text-builder.ts +2 -2
  65. package/src/text/text-truncate.ts +101 -0
  66. package/src/tokens/tokens.ts +107 -16
  67. package/src/tokens/variables.ts +203 -46
  68. package/templates/scan-components-route.ts +8 -0
  69. package/ui.html +144 -43
@@ -84,7 +84,15 @@ function buildResponsiveBuckets(classes: string[]): Record<string, string[]> {
84
84
  return buckets;
85
85
  }
86
86
 
87
- const DISPLAY_UTILITIES = new Set([
87
+ /**
88
+ * Every Tailwind `display` utility, including `hidden`. Shared with any
89
+ * call site that needs to ask "is class X a display utility?" — keeps
90
+ * the set in one place so adding a new utility lights up uniformly
91
+ * across the codebase. `width-solver.ts` keeps its own subset that
92
+ * excludes `hidden` for its own narrower "active display for layout"
93
+ * purpose; this set is for last-wins / hidden-cascade resolution.
94
+ */
95
+ export const DISPLAY_UTILITIES = new Set([
88
96
  'hidden', 'block', 'inline-block', 'inline', 'flex', 'inline-flex',
89
97
  'grid', 'inline-grid', 'table', 'inline-table', 'contents', 'list-item',
90
98
  'flow-root', 'table-row', 'table-cell', 'table-caption', 'table-column',
@@ -99,15 +107,61 @@ const DISPLAY_UTILITIES = new Set([
99
107
  */
100
108
  export function isHiddenAtBreakpoint(classes: string[], breakpoint: string): boolean {
101
109
  const effective = getClassesForBreakpoint(classes, breakpoint);
110
+ return isEffectivelyHidden(effective);
111
+ }
112
+
113
+ /**
114
+ * Returns true when an already-resolved (cascaded) class list ends in
115
+ * `display: hidden`. Used by the per-element renderer to skip nodes
116
+ * whose `hidden` / `sm:hidden` / `hidden sm:inline` pattern resolves
117
+ * to `display: none` at the current viewport — without this check,
118
+ * both halves of a `<span sm:hidden>A</span><span hidden sm:inline>B</span>`
119
+ * pair render as visible siblings in Figma.
120
+ *
121
+ * Caller is responsible for passing the breakpoint-cascaded class
122
+ * list (e.g. the output of `resolveClassesForBreakpoint`); this
123
+ * helper applies the last-wins rule across display utilities only.
124
+ */
125
+ export function isEffectivelyHidden(resolvedClasses: string[]): boolean {
102
126
  let lastDisplay: string | null = null;
103
- for (let i = 0; i < effective.length; i++) {
104
- const cls = effective[i];
127
+ for (let i = 0; i < resolvedClasses.length; i++) {
128
+ const cls = resolvedClasses[i];
105
129
  const bare = cls.charAt(0) === '!' ? cls.slice(1) : cls;
106
130
  if (DISPLAY_UTILITIES.has(bare)) lastDisplay = bare;
107
131
  }
108
132
  return lastDisplay === 'hidden';
109
133
  }
110
134
 
135
+ /**
136
+ * Returns true when the given resolved class list ends in either
137
+ * `display: hidden` (per `isEffectivelyHidden`) OR `sr-only` without a
138
+ * later display utility that visually re-shows the element.
139
+ *
140
+ * Use this at every render-time gate that decides "skip this element
141
+ * entirely". A previous-life version of this rule was duplicated as
142
+ * `classes.includes('hidden') || classes.includes('sr-only')` at three
143
+ * different sites — each missing the last-wins / override semantics.
144
+ * The recurring bug was a `<p hidden sm:block>` losing its text because
145
+ * one of those sites saw `hidden` in the list and short-circuited even
146
+ * though the sm+ cascade resolved to `block` (visible). One helper now
147
+ * makes the rule impossible to get wrong.
148
+ */
149
+ export function isEffectivelyHiddenOrSrOnly(resolvedClasses: string[]): boolean {
150
+ if (isEffectivelyHidden(resolvedClasses)) return true;
151
+ // sr-only is not a CSS `display` value but Tailwind treats it as
152
+ // "visually hidden" via position+clip. Honour `not-sr-only` cascading
153
+ // after it (e.g. `sr-only sm:not-sr-only`) by looking at the last
154
+ // sr-only-ish utility — sr-only wins unless `not-sr-only` comes later.
155
+ let lastSrOnly: 'sr-only' | 'not-sr-only' | null = null;
156
+ for (let i = 0; i < resolvedClasses.length; i++) {
157
+ const cls = resolvedClasses[i];
158
+ const bare = cls.charAt(0) === '!' ? cls.slice(1) : cls;
159
+ if (bare === 'sr-only') lastSrOnly = 'sr-only';
160
+ else if (bare === 'not-sr-only') lastSrOnly = 'not-sr-only';
161
+ }
162
+ return lastSrOnly === 'sr-only';
163
+ }
164
+
111
165
  export function extractBreakpointsFromClasses(classes: string[]): BreakpointInfo[] {
112
166
  const buckets = buildResponsiveBuckets(classes);
113
167
  const hasResponsive = BREAKPOINT_ORDER.some(bp => (buckets[bp] && buckets[bp].length > 0));
@@ -4,7 +4,7 @@ import { COMPONENT_DEFS } from '../tokens';
4
4
  import { parseColor, nearestColorToken } from '../tokens';
5
5
  import type { RGB } from '../tokens';
6
6
  import { getComponentDef } from '../components';
7
- import { bindColorVariable } from '../tokens';
7
+ import { applyTokenColor } from '../tokens';
8
8
  import {
9
9
  parseUtilityClass,
10
10
  spacingValue,
@@ -45,18 +45,69 @@ import {
45
45
  // Types
46
46
  // ---------------------------------------------------------------------------
47
47
 
48
+ /**
49
+ * The kinds of color-bearing Tailwind utilities the plugin currently
50
+ * understands. Each one maps to a single triple of fields on
51
+ * `TailwindStyle` (color value, variable token, alpha) and a Figma slot
52
+ * (fill or stroke).
53
+ *
54
+ * Universal touch-point: changing a color slot's parsing / application
55
+ * means editing this table + `COLOR_SLOT_FIELDS` + `COLOR_SLOT_FIGMA`
56
+ * once. The 9 parallel TailwindStyle fields stay flat for cheap reads,
57
+ * but the slot-specific *logic* never needs to special-case bg / text /
58
+ * border again.
59
+ */
60
+ export type ColorSlot = 'bg' | 'text' | 'border';
61
+
62
+ /** Field-name triple per slot — single mapping used by every accessor. */
63
+ const COLOR_SLOT_FIELDS = {
64
+ bg: { value: 'bg', token: 'bgToken', opacity: 'bgOpacity' },
65
+ text: { value: 'text', token: 'textToken', opacity: 'textOpacity' },
66
+ border: { value: 'border', token: 'borderToken', opacity: 'borderOpacity' },
67
+ } as const;
68
+
48
69
  export interface TailwindStyle {
49
70
  bg: string | null;
50
71
  bgToken: string | null;
51
72
  bgOpacity?: number;
52
73
  text: string | null;
53
74
  textToken: string | null;
75
+ textOpacity?: number;
54
76
  border: string | null;
55
77
  borderToken: string | null;
78
+ borderOpacity?: number;
56
79
  opacity: number | null;
57
80
  underline?: boolean;
58
81
  }
59
82
 
83
+ /** Set a color slot's value/token/opacity in one call — universal. */
84
+ function setColorSlot(
85
+ style: TailwindStyle,
86
+ slot: ColorSlot,
87
+ value: string | null,
88
+ token: string | null,
89
+ opacity: number | null,
90
+ ): void {
91
+ const fields = COLOR_SLOT_FIELDS[slot];
92
+ (style as unknown as Record<string, unknown>)[fields.value] = value;
93
+ (style as unknown as Record<string, unknown>)[fields.token] = token;
94
+ if (opacity !== null) (style as unknown as Record<string, unknown>)[fields.opacity] = opacity;
95
+ }
96
+
97
+ /** Set only the opacity of a color slot — used by the styleMap fallback. */
98
+ function setColorSlotOpacity(style: TailwindStyle, slot: ColorSlot, alpha: number): void {
99
+ const fields = COLOR_SLOT_FIELDS[slot];
100
+ (style as unknown as Record<string, unknown>)[fields.opacity] = alpha;
101
+ }
102
+
103
+ /** Classify a Tailwind utility into its color slot — `null` if not a color class. */
104
+ function classifyColorSlot(utility: string): ColorSlot | null {
105
+ if (utility.startsWith('bg-')) return 'bg';
106
+ if (utility.startsWith('text-')) return 'text';
107
+ if (utility.startsWith('border-')) return 'border';
108
+ return null;
109
+ }
110
+
60
111
  export interface FrameProperties {
61
112
  cornerRadius: number;
62
113
  paddingTop: number;
@@ -108,12 +159,53 @@ function getPaletteTokens(): Record<string, string> {
108
159
  }
109
160
 
110
161
  function cssVarToToken(value: string): string | null {
111
- const varMatch = value.match(/var\\(--([^)]+)\\)/);
162
+ const varMatch = value.match(/var\(--([^)]+)\)/);
112
163
  if (!varMatch) return null;
113
164
  const token = varMatch[1].replace(/^color-/, '');
114
165
  return token || null;
115
166
  }
116
167
 
168
+ /**
169
+ * Extract a 0..1 alpha from a CSS color value. Returns `null` when the
170
+ * value carries no explicit alpha (caller falls back to 1).
171
+ *
172
+ * Tailwind v4 compiles `bg-token/N` to a `color-mix` against transparent —
173
+ * `color-mix(in oklch, var(--muted) 10%, transparent)` — so the styleMap-
174
+ * driven path through `applyCssDeclarations` used to drop the alpha
175
+ * entirely (only the variable token was lifted, the `10%` was lost). Net
176
+ * effect: `bg-muted/10` rendered at full opacity (a heavy grey panel)
177
+ * instead of the intended near-invisible overlay.
178
+ *
179
+ * Recognised shapes:
180
+ * - `color-mix(in <space>, <color> N%, transparent)` — Tailwind v4 default
181
+ * - `rgba(...)` / `hsla(...)` — legacy 4-arg form
182
+ * - `rgb(.../ N%)` / `hsl(.../ N%)` / `oklch(.../ N)` / `lab(.../ N)` —
183
+ * modern slash-alpha (CSS Color 4)
184
+ */
185
+ function extractAlphaFromColorValue(value: string): number | null {
186
+ if (!value) return null;
187
+ const mix = value.match(/color-mix\([^,]+,\s*[^,]+?\s+(\d+(?:\.\d+)?)\s*%\s*,\s*transparent\s*\)/i);
188
+ if (mix) {
189
+ const pct = parseFloat(mix[1]);
190
+ if (Number.isFinite(pct)) return Math.max(0, Math.min(1, pct / 100));
191
+ }
192
+ const legacy = value.match(/(?:rgba|hsla)\(\s*[^)]*,\s*([0-9.]+)\s*\)/i);
193
+ if (legacy) {
194
+ const a = parseFloat(legacy[1]);
195
+ if (Number.isFinite(a)) return Math.max(0, Math.min(1, a));
196
+ }
197
+ const slash = value.match(/\/\s*([0-9.]+)\s*(%?)\s*\)/);
198
+ if (slash) {
199
+ const num = parseFloat(slash[1]);
200
+ const isPct = slash[2] === '%';
201
+ if (Number.isFinite(num)) {
202
+ const a = isPct ? num / 100 : num;
203
+ return Math.max(0, Math.min(1, a));
204
+ }
205
+ }
206
+ return null;
207
+ }
208
+
117
209
  function resolveSpacingToken(token: string, spacingScale: Record<string, number>): number | null {
118
210
  if (!token) return null;
119
211
  if (token.startsWith('[') && token.endsWith(']')) {
@@ -149,6 +241,12 @@ function applyCssDeclarations(style: TailwindStyle, declarations: Record<string,
149
241
  style.bg = value;
150
242
  const token = cssVarToToken(value);
151
243
  if (token) style.bgToken = token;
244
+ // Tailwind v4 bakes `/N` alpha into a `color-mix(... var(--x) N%,
245
+ // transparent)` so the variable token alone doesn't carry the
246
+ // opacity. Extract the alpha here so the downstream bg-apply path
247
+ // honors `bg-token/N` the same way it does for `bg-token`.
248
+ const alpha = extractAlphaFromColorValue(value);
249
+ if (alpha !== null) style.bgOpacity = alpha;
152
250
  } else if (prop === 'color') {
153
251
  style.text = value;
154
252
  const token = cssVarToToken(value);
@@ -328,15 +426,20 @@ function applyCssDeclarationsToFrame(frame: FrameNode, declarations: Record<stri
328
426
  continue;
329
427
  }
330
428
  if (prop === 'display' && (value === 'grid' || value === 'inline-grid')) {
331
- if (declaredGridColumns != null) {
429
+ // `grid-cols-1` (1 explicit column) is CSS-semantically a vertical
430
+ // stack — same as `grid` without any column class. Only flip to
431
+ // HORIZONTAL+WRAP when there are >=2 columns; otherwise route through
432
+ // the vertical-flow path so itemSpacing (primary axis = vertical)
433
+ // applies correctly and children don't get a stale horizontal layout.
434
+ if (declaredGridColumns != null && declaredGridColumns >= 2) {
332
435
  frame.layoutMode = 'HORIZONTAL';
333
436
  markGridColumnsNode(frame, declaredGridColumns);
334
437
  if (frame.layoutWrap !== undefined) {
335
438
  frame.layoutWrap = 'WRAP';
336
439
  }
337
440
  } else {
338
- // CSS grid without explicit template columns behaves like a single-column
339
- // vertical flow in our Figma mapping.
441
+ // CSS grid without explicit template columns (or with exactly 1
442
+ // column) behaves like a single-column vertical flow.
340
443
  frame.layoutMode = 'VERTICAL';
341
444
  markCssGridVerticalFrame(frame);
342
445
  }
@@ -541,6 +644,28 @@ function applyCssDeclarationsToFrame(frame: FrameNode, declarations: Record<stri
541
644
  // Section 1 – Tailwind-to-Style conversion (lines 194-425 of code.js)
542
645
  // ---------------------------------------------------------------------------
543
646
 
647
+ /**
648
+ * Split a Tailwind color modifier `token/N` (e.g. `muted/10`,
649
+ * `foreground/30`) into the base token and a 0..1 alpha. Returns
650
+ * `alpha=null` when no `/N` modifier is present.
651
+ *
652
+ * Single source of truth for the `/N` parsing — used by every code
653
+ * path that needs to interpret partial-opacity color modifiers
654
+ * (the semantic-style fast path, the styleMap fallback, and any
655
+ * future call site). Without this, the same regex shape lived
656
+ * inline in multiple places and the two copies drifted apart on
657
+ * edge cases (e.g. one tolerated leading whitespace, the other
658
+ * didn't), which made the `bg-muted/10` rendering bug harder to
659
+ * pin down than it should have been.
660
+ */
661
+ export function splitColorAlpha(token: string): { token: string; alpha: number | null } {
662
+ const match = token.match(/^(.+)\/(\d+)$/);
663
+ if (!match) return { token, alpha: null };
664
+ const pct = parseInt(match[2], 10);
665
+ if (!Number.isFinite(pct)) return { token, alpha: null };
666
+ return { token: match[1], alpha: Math.max(0, Math.min(1, pct / 100)) };
667
+ }
668
+
544
669
  /**
545
670
  * Extract color token name from a Tailwind class
546
671
  * e.g., "bg-primary" -> "primary", "text-primary-foreground" -> "primary-foreground"
@@ -609,30 +734,17 @@ function applySemanticStyleForUtility(
609
734
  }
610
735
  }
611
736
 
612
- const token = extractColorToken(utility);
613
- if (!token) return false;
737
+ const rawToken = extractColorToken(utility);
738
+ if (!rawToken) return false;
614
739
 
615
- const opacityMatch = token.match(/^(.+)\/(\d+)$/);
616
- const actualToken = opacityMatch ? opacityMatch[1] : token;
617
- const colorOpacity = opacityMatch ? parseInt(opacityMatch[2], 10) / 100 : null;
740
+ const { token: actualToken, alpha: colorOpacity } = splitColorAlpha(rawToken);
618
741
  const palette = getPaletteTokens();
619
742
  const color = colorGroup[actualToken] || palette[actualToken] || FALLBACK_COLOR_TOKENS[actualToken];
620
743
  if (!color) return false;
621
744
 
622
- if (utility.startsWith('bg-')) {
623
- style.bg = color;
624
- style.bgToken = actualToken;
625
- if (colorOpacity !== null) style.bgOpacity = colorOpacity;
626
- return true;
627
- }
628
- if (utility.startsWith('text-')) {
629
- style.text = color;
630
- style.textToken = actualToken;
631
- return true;
632
- }
633
- if (utility.startsWith('border-')) {
634
- style.border = color;
635
- style.borderToken = actualToken;
745
+ const slot = classifyColorSlot(utility);
746
+ if (slot) {
747
+ setColorSlot(style, slot, color, actualToken, colorOpacity);
636
748
  return true;
637
749
  }
638
750
 
@@ -791,6 +903,27 @@ export function tailwindClassesToStyle(
791
903
  for (const entry of entryList) {
792
904
  applyCssDeclarations(style, entry.declarations);
793
905
  }
906
+ // Slash-alpha (`bg-muted/10`, `border-foreground/30`, …) is part of
907
+ // the *class name*, not the declarations. Tailwind v4's styleMap
908
+ // entry can be the bare `background-color: var(--muted)` with the
909
+ // 10 % dropped on the floor (depends on how the compiler chose to
910
+ // emit it for the given consumer). Lift the alpha straight off
911
+ // `cls` so the consumer's intent survives that compiler choice.
912
+ // Only fires when `applySemanticStyleForUtility` didn't already
913
+ // handle this utility (we `continue` above when it does).
914
+ // Lift the `/N` alpha straight off the class name via the shared
915
+ // splitter (`splitColorAlpha`) and route it through the universal
916
+ // slot assignment. Works for every color kind we model — bg, text,
917
+ // border. Tailwind v4's compiled CSS sometimes drops the alpha
918
+ // (`bg-muted/10` → `background-color: var(--muted)` with the 10%
919
+ // lost), so the class name is the canonical source of intent.
920
+ const base = cls.replace(/^(?:[a-z0-9]+:)+/, '');
921
+ const slot = classifyColorSlot(base);
922
+ if (slot) {
923
+ const tokenPart = base.slice(slot.length + 1); // strip "bg-" / "text-" / "border-"
924
+ const { alpha } = splitColorAlpha(tokenPart);
925
+ if (alpha !== null) setColorSlotOpacity(style, slot, alpha);
926
+ }
794
927
  }
795
928
  }
796
929
 
@@ -821,6 +954,119 @@ export function getButtonStyleFromDefs(
821
954
  return tailwindClassesToStyle(classes, state, colorGroup);
822
955
  }
823
956
 
957
+ type ShadowDef = {
958
+ x: number;
959
+ y: number;
960
+ radius: number;
961
+ spread: number;
962
+ r: number;
963
+ g: number;
964
+ b: number;
965
+ a: number;
966
+ type: 'DROP_SHADOW' | 'INNER_SHADOW';
967
+ };
968
+
969
+ /**
970
+ * Parse a Tailwind arbitrary-value shadow string into our internal shadow
971
+ * def shape. Mirrors CSS `box-shadow` syntax:
972
+ * [inset?] <offset-x> <offset-y> [<blur>] [<spread>] [<color>]
973
+ * Tailwind uses underscores in place of spaces inside the `shadow-[…]`
974
+ * bracket (because spaces would break the class-name token), so we swap
975
+ * those back to spaces before tokenising. Lengths must carry a `px` /
976
+ * `rem` unit (or be `0`); the color can be `rgba(...)` / `rgb(...)` /
977
+ * `hsla(...)` / `#hex`.
978
+ *
979
+ * Returns `null` (caller falls back to the SHADOW_MAP preset lookup, or
980
+ * to "unknown shadow → skip") when the value isn't parseable.
981
+ *
982
+ * Real-world trigger: greenhouse-app IncreasePositionModal's submit
983
+ * button uses `shadow-[0_8px_16px_rgba(16,185,129,0.35)]` for a coloured
984
+ * lift effect. Without arbitrary-value support the button rendered flat.
985
+ */
986
+ function parseArbitraryBoxShadow(raw: string): ShadowDef[] | null {
987
+ if (!raw) return null;
988
+ // Swap _ → space; commas / parens stay intact (rgba() is a single token).
989
+ const normalized = raw.replace(/_/g, ' ').trim();
990
+ if (!normalized) return null;
991
+
992
+ let cursor = normalized;
993
+ let isInset = false;
994
+ if (cursor.startsWith('inset ')) {
995
+ isInset = true;
996
+ cursor = cursor.slice('inset '.length).trim();
997
+ }
998
+
999
+ // Split into tokens — respect parens (so `rgba(16,185,129,0.35)` stays one token).
1000
+ const tokens: string[] = [];
1001
+ let depth = 0;
1002
+ let current = '';
1003
+ for (const ch of cursor) {
1004
+ if (ch === '(') depth++;
1005
+ else if (ch === ')') depth--;
1006
+ if (ch === ' ' && depth === 0) {
1007
+ if (current.length > 0) {
1008
+ tokens.push(current);
1009
+ current = '';
1010
+ }
1011
+ continue;
1012
+ }
1013
+ current += ch;
1014
+ }
1015
+ if (current.length > 0) tokens.push(current);
1016
+
1017
+ if (tokens.length < 2) return null;
1018
+
1019
+ // The trailing color is whichever token isn't parse-able as a length.
1020
+ // Most consumer shadows put the color last; if no token is colour-shaped
1021
+ // assume the last one is.
1022
+ const lengthTokens: string[] = [];
1023
+ let colorToken: string | null = null;
1024
+ for (const tok of tokens) {
1025
+ if (colorToken == null && (
1026
+ tok.startsWith('rgb') || tok.startsWith('hsl') || tok.startsWith('oklch') ||
1027
+ tok.startsWith('#') || tok === 'currentColor' || tok === 'transparent'
1028
+ )) {
1029
+ colorToken = tok;
1030
+ } else {
1031
+ lengthTokens.push(tok);
1032
+ }
1033
+ }
1034
+ if (lengthTokens.length < 2) return null;
1035
+ if (colorToken == null) {
1036
+ // Treat the final token as color when nothing matched a colour prefix.
1037
+ colorToken = lengthTokens.pop() || '';
1038
+ }
1039
+
1040
+ const parseLen = (s: string): number => {
1041
+ const m = s.match(/^(-?\d+(?:\.\d+)?)(px|rem|em)?$/);
1042
+ if (!m) return NaN;
1043
+ const n = parseFloat(m[1]);
1044
+ const unit = m[2] || 'px';
1045
+ if (unit === 'rem' || unit === 'em') return n * 16;
1046
+ return n;
1047
+ };
1048
+ const x = parseLen(lengthTokens[0]);
1049
+ const y = parseLen(lengthTokens[1]);
1050
+ const blur = lengthTokens[2] != null ? parseLen(lengthTokens[2]) : 0;
1051
+ const spread = lengthTokens[3] != null ? parseLen(lengthTokens[3]) : 0;
1052
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(blur) || !Number.isFinite(spread)) {
1053
+ return null;
1054
+ }
1055
+
1056
+ const color = parseColor(colorToken);
1057
+ return [{
1058
+ x,
1059
+ y,
1060
+ radius: blur,
1061
+ spread,
1062
+ r: color.r,
1063
+ g: color.g,
1064
+ b: color.b,
1065
+ a: color.a == null ? 1 : color.a,
1066
+ type: isInset ? 'INNER_SHADOW' : 'DROP_SHADOW',
1067
+ }];
1068
+ }
1069
+
824
1070
  function applySemanticUtilitiesToFrame(
825
1071
  frame: FrameNode,
826
1072
  classes: string[],
@@ -842,6 +1088,13 @@ function applySemanticUtilitiesToFrame(
842
1088
  const atom = parseUtilityClass(cls);
843
1089
  if (!atom.utility) continue;
844
1090
 
1091
+ // Filter breakpoint-prefixed atoms FIRST so utilities like `sm:grid-cols-2`
1092
+ // don't leak through to `markGridColumnsNode` at the base breakpoint.
1093
+ // The responsive-analyzer normally strips breakpoint prefixes before
1094
+ // calling here, but defense-in-depth: any atom carrying a non-active
1095
+ // variant must be skipped before any structural side-effects run.
1096
+ if (!shouldApplyAtom(atom, 'default')) continue;
1097
+
845
1098
  const utility = atom.utility;
846
1099
 
847
1100
  const gridColsMatch = utility.match(/^grid-cols-(\d+)$/);
@@ -860,8 +1113,6 @@ function applySemanticUtilitiesToFrame(
860
1113
  continue;
861
1114
  }
862
1115
 
863
- if (!shouldApplyAtom(atom, 'default')) continue;
864
-
865
1116
  if (utility === 'flex' || utility === 'inline-flex') {
866
1117
  // Bare `flex` enables flex layout but does not set direction. Only write
867
1118
  // HORIZONTAL when the direction hasn't been declared yet by a prior
@@ -906,14 +1157,25 @@ function applySemanticUtilitiesToFrame(
906
1157
  continue;
907
1158
  }
908
1159
  if (utility === 'grid' || utility === 'inline-grid') {
909
- const hasColumns = classes.some((c: string) => /^grid-cols-\d+$/.test(c));
910
- if (hasColumns) {
1160
+ // Match parseLayoutMode + applyCssDeclarationsToFrame: only flip
1161
+ // to HORIZONTAL+WRAP when there are >=2 columns. `grid-cols-1` is
1162
+ // CSS-semantically a vertical stack (single column = no horizontal
1163
+ // flow) and must route through the VERTICAL path so itemSpacing
1164
+ // applies on the right axis. The `mapClassNameForBreakpoint`
1165
+ // helper injects `grid-cols-1` at the base breakpoint when the
1166
+ // source had a responsive `sm:grid-cols-N`, so this case is hit
1167
+ // any time a grid with responsive columns renders at base.
1168
+ const hasMultiColumns = classes.some((c: string) => {
1169
+ const match = c.match(/^grid-cols-(\d+)$/);
1170
+ return match != null && parseInt(match[1], 10) >= 2;
1171
+ });
1172
+ if (hasMultiColumns) {
911
1173
  frame.layoutMode = 'HORIZONTAL';
912
1174
  if (frame.layoutWrap !== undefined) {
913
1175
  frame.layoutWrap = 'WRAP';
914
1176
  }
915
1177
  } else {
916
- // Single-column implicit grid → VERTICAL; children stretch to fill (CSS default align-items: stretch)
1178
+ // Single-column implicit grid or `grid-cols-1` → VERTICAL; children stretch to fill (CSS default align-items: stretch)
917
1179
  frame.layoutMode = 'VERTICAL';
918
1180
  markCssGridVerticalFrame(frame);
919
1181
  }
@@ -1153,7 +1415,17 @@ function applySemanticUtilitiesToFrame(
1153
1415
  '2xl': [{ x: 0, y: 25, radius: 50, spread: 0, r: 0, g: 0, b: 0, a: 0.25, type: 'DROP_SHADOW' }],
1154
1416
  'inner': [{ x: 0, y: 2, radius: 4, spread: 0, r: 0, g: 0, b: 0, a: 0.06, type: 'INNER_SHADOW' }],
1155
1417
  };
1156
- const defs = SHADOW_MAP[shadowKey];
1418
+ // Tailwind arbitrary-value shadow:
1419
+ // `shadow-[0_8px_16px_rgba(16,185,129,0.35)]`
1420
+ // → offset-x 0, offset-y 8px, blur 16px, color rgba(16,185,129,0.35).
1421
+ // Underscores stand in for the spaces CSS allows in `box-shadow`.
1422
+ // Real-world trigger: greenhouse-app's IncreasePositionModal submit
1423
+ // button uses a coloured-glow shadow to lift the CTA — without this
1424
+ // path the rendered button sat flat against the modal.
1425
+ const arbitraryShadow = shadowKey.startsWith('[') && shadowKey.endsWith(']')
1426
+ ? parseArbitraryBoxShadow(shadowKey.slice(1, -1))
1427
+ : null;
1428
+ const defs = arbitraryShadow != null ? arbitraryShadow : SHADOW_MAP[shadowKey];
1157
1429
  if (defs !== undefined) {
1158
1430
  // Figma renders multi-layer DROP_SHADOW effects as independently
1159
1431
  // stacked filters — they composite "harder" than CSS box-shadow,
@@ -1675,34 +1947,55 @@ export function applyTailwindStylesToFrame(
1675
1947
  hasGradient = true;
1676
1948
  }
1677
1949
 
1678
- // Apply background - try variable binding first, fall back to raw color
1950
+ // Apply background and border via the universal `applyTokenColor` helper
1951
+ // single source of truth for variable-binding + opacity + literal fallback.
1952
+ // See `tokens/variables.ts` for why partial alpha skips variable binding
1953
+ // (Figma's plugin API normalises bound paint opacity to 1 on reassign).
1679
1954
  if (style.bg && !hasGradient) {
1680
- const bgToken = style.bgToken;
1681
- const bound = bgToken && bindColorVariable(frame, bgToken, 'fill', theme);
1682
- if (bound) {
1683
- if (style.bgOpacity != null && Array.isArray(frame.fills) && frame.fills.length > 0) {
1684
- const nextFills = JSON.parse(JSON.stringify(frame.fills));
1685
- nextFills[0].opacity = style.bgOpacity;
1686
- frame.fills = nextFills;
1687
- }
1688
- } else {
1689
- const bg = parseColor(style.bg);
1690
- const bgOpacity = style.bgOpacity != null ? style.bgOpacity : (bg.a == null ? 1 : bg.a);
1691
- frame.fills = [{ type: 'SOLID', color: { r: bg.r, g: bg.g, b: bg.b }, opacity: bgOpacity }];
1692
- }
1955
+ applyTokenColor(frame, 'fill', {
1956
+ token: style.bgToken,
1957
+ value: style.bg,
1958
+ opacity: style.bgOpacity,
1959
+ }, theme);
1693
1960
  }
1694
1961
 
1695
- // Apply border - try variable binding first, fall back to raw color
1696
1962
  if (style.border) {
1697
- const borderToken = style.borderToken;
1698
- const borderBound = borderToken && bindColorVariable(frame, borderToken, 'stroke', theme);
1699
- if (!borderBound) {
1700
- const borderColor = parseColor(style.border);
1701
- frame.strokes = [{ type: 'SOLID', color: { r: borderColor.r, g: borderColor.g, b: borderColor.b } }];
1702
- }
1963
+ applyTokenColor(frame, 'stroke', {
1964
+ token: style.borderToken,
1965
+ value: style.border,
1966
+ opacity: style.borderOpacity,
1967
+ }, theme);
1703
1968
  frame.strokeWeight = 1;
1704
1969
  }
1705
1970
 
1971
+ // Border style utilities (`border-dashed`, `border-dotted`, `border-solid`)
1972
+ // map to Figma's `dashPattern`. Tailwind doesn't add styles by class; they
1973
+ // override the default "solid" CSS border style. Figma uses dashPattern =
1974
+ // [] for solid, [dash, gap] for dashed/dotted.
1975
+ //
1976
+ // Values picked to mirror typical browser-default dashed/dotted patterns
1977
+ // (Chrome / Safari). Real-world trigger: greenhouse-app's
1978
+ // IncreasePositionModal "Position impact" card uses
1979
+ // `<div className="my-1 h-px border-t border-dashed border-muted-foreground/30" />`
1980
+ // as a separator between the side badge and the metrics grid. Without
1981
+ // dashPattern handling it rendered as a solid line.
1982
+ //
1983
+ // The check runs whenever ANY of these classes is present, even when
1984
+ // there's no border color yet — covers borders applied via `divide-*`,
1985
+ // ring overlays, or other paths that produce a stroke later.
1986
+ if ('dashPattern' in frame) {
1987
+ let dashPattern: number[] | null = null;
1988
+ for (const cls of classes) {
1989
+ if (cls === 'border-dashed') dashPattern = [4, 4];
1990
+ else if (cls === 'border-dotted') dashPattern = [1, 2];
1991
+ else if (cls === 'border-solid' || cls === 'border-none') dashPattern = [];
1992
+ }
1993
+ if (dashPattern !== null) {
1994
+ try { (frame as unknown as { dashPattern: number[] }).dashPattern = dashPattern; }
1995
+ catch (_err) { /* ignore unsupported types */ }
1996
+ }
1997
+ }
1998
+
1706
1999
  const hasBorderWidth = classes.some(cls => {
1707
2000
  const atom = parseUtilityClass(cls);
1708
2001
  if (!atom.utility) return false;
package/src/text/index.ts CHANGED
@@ -2,3 +2,4 @@ export * from './text-builder';
2
2
  export * from './text-line';
3
3
  export * from './inline-text';
4
4
  export * from './font-style-resolver';
5
+ export * from './text-truncate';