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.
- package/README.md +29 -0
- package/code.js +15 -15
- package/manifest.json +1 -2
- package/package.json +40 -22
- package/scanner/border-dash-pattern-regression.ts +163 -0
- package/scanner/child-sizing-matrix-regression.ts +9 -0
- package/scanner/cli.ts +21 -5
- package/scanner/component-scanner.ts +1333 -77
- package/scanner/conditional-map-branch-regression.ts +180 -0
- package/scanner/css-token-reader.ts +66 -5
- package/scanner/dialog-content-gate-regression.ts +195 -0
- package/scanner/expression-evaluator-regression.ts +432 -0
- package/scanner/framework-adapter-shadcn-regression.ts +157 -1
- package/scanner/hidden-check-drift-regression.ts +125 -0
- package/scanner/horizontal-text-shrink-regression.ts +230 -0
- package/scanner/imported-array-map-regression.ts +195 -0
- package/scanner/inline-flex-regression.ts +5 -0
- package/scanner/intrinsic-sizing-regression.ts +333 -0
- package/scanner/portal-class-strip-regression.ts +109 -0
- package/scanner/responsive-hidden-inline-regression.ts +226 -0
- package/scanner/responsive-opt-in-regression.ts +212 -0
- package/scanner/select-root-flatten-regression.ts +314 -0
- package/scanner/space-between-single-child-regression.ts +163 -0
- package/scanner/story-args-resolution-regression.ts +311 -0
- package/scanner/story-dimensioning-regression.ts +76 -1
- package/scanner/style-map.ts +57 -0
- package/scanner/table-column-alignment-regression.ts +355 -0
- package/scanner/ternary-fragment-branch-regression.ts +196 -0
- package/scanner/text-truncate-regression.ts +481 -0
- package/scanner/types.ts +13 -0
- package/src/components/component-gen.ts +21 -38
- package/src/design-system/cva-master.ts +11 -18
- package/src/design-system/design-system.ts +36 -7
- package/src/design-system/frame-stabilizers.ts +109 -12
- package/src/design-system/preview-builder.ts +38 -0
- package/src/design-system/selectable-state.ts +8 -1
- package/src/design-system/story-builder.ts +62 -32
- package/src/design-system/story-dimensioning.ts +14 -3
- package/src/design-system/tag-predicates.ts +8 -0
- package/src/design-system/typography.ts +26 -0
- package/src/design-system/ui-builder.ts +188 -60
- package/src/effects/icon-builder.ts +8 -0
- package/src/framework-adapters/shadcn.ts +113 -0
- package/src/github/github.ts +22 -4
- package/src/layout/index.ts +4 -0
- package/src/layout/intrinsic-applier.ts +105 -0
- package/src/layout/intrinsic-sizing.ts +183 -0
- package/src/layout/layout-parser.ts +36 -0
- package/src/layout/parser/layout-mode.ts +14 -4
- package/src/layout/table-layout.ts +271 -0
- package/src/layout/text-truncate-pass.ts +151 -0
- package/src/layout/width-solver.ts +63 -17
- package/src/main.ts +37 -124
- package/src/plugin/config.ts +21 -0
- package/src/plugin/packs/pack-provider.ts +20 -4
- package/src/plugin/packs/packs.ts +14 -0
- package/src/render-engine-version.ts +1 -1
- package/src/tailwind/jsx-utils.ts +39 -0
- package/src/tailwind/node-ir.ts +8 -1
- package/src/tailwind/responsive-analyzer.ts +57 -3
- package/src/tailwind/tailwind.ts +344 -51
- package/src/text/index.ts +1 -0
- package/src/text/inline-text.ts +112 -12
- package/src/text/text-builder.ts +2 -2
- package/src/text/text-truncate.ts +101 -0
- package/src/tokens/tokens.ts +107 -16
- package/src/tokens/variables.ts +203 -46
- package/templates/scan-components-route.ts +8 -0
- 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
|
-
|
|
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 <
|
|
104
|
-
const cls =
|
|
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));
|
package/src/tailwind/tailwind.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
339
|
-
//
|
|
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
|
|
613
|
-
if (!
|
|
737
|
+
const rawToken = extractColorToken(utility);
|
|
738
|
+
if (!rawToken) return false;
|
|
614
739
|
|
|
615
|
-
const
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
style
|
|
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
|
-
|
|
910
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
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
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
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