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
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
applyNodeTransforms,
|
|
13
13
|
getCompoundClasses,
|
|
14
14
|
getClassesForBreakpoint,
|
|
15
|
+
isEffectivelyHiddenOrSrOnly,
|
|
15
16
|
isElementLikeNode,
|
|
16
17
|
mergeClasses,
|
|
17
18
|
resolveNodeIR,
|
|
@@ -70,12 +71,15 @@ import {
|
|
|
70
71
|
} from '../components';
|
|
71
72
|
import {
|
|
72
73
|
TAG_TYPOGRAPHY,
|
|
74
|
+
applyTruncation,
|
|
73
75
|
createInlineTextNode,
|
|
74
76
|
createTextNode,
|
|
75
77
|
getLineHeightFromClasses,
|
|
76
78
|
getNodeTextStyle,
|
|
77
79
|
getTextAlignFromClasses,
|
|
80
|
+
getTruncateFromClasses,
|
|
78
81
|
isInlineTextNode,
|
|
82
|
+
markForTruncation,
|
|
79
83
|
maybeExpandTextLineComponent,
|
|
80
84
|
type CreateTextOptions,
|
|
81
85
|
} from '../text';
|
|
@@ -112,6 +116,7 @@ import {
|
|
|
112
116
|
isTabsContentTag,
|
|
113
117
|
isSelectRootTag,
|
|
114
118
|
isSelectContentTag,
|
|
119
|
+
isDialogContentTag,
|
|
115
120
|
isSelectTriggerTag,
|
|
116
121
|
isSelectValueTag,
|
|
117
122
|
isSelectItemTag,
|
|
@@ -158,6 +163,7 @@ import {
|
|
|
158
163
|
getFontStyleFromClasses,
|
|
159
164
|
getResolvedTextSizeFromClasses,
|
|
160
165
|
getResolvedBoldFromClasses,
|
|
166
|
+
getFontRoleFromClasses,
|
|
161
167
|
} from './typography';
|
|
162
168
|
import { expandActiveConditionalVariants } from './node-variants';
|
|
163
169
|
import {
|
|
@@ -934,18 +940,17 @@ function buildFigmaNode(
|
|
|
934
940
|
opts.textAlignHorizontal = context.textAlign;
|
|
935
941
|
}
|
|
936
942
|
const textNode = createTextNode(node.text, opts);
|
|
937
|
-
if (context.textTruncate
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
textNode
|
|
945
|
-
} catch (_err) {
|
|
946
|
-
// ignore — truncation not supported in all plugin contexts
|
|
943
|
+
if (context.textTruncate) {
|
|
944
|
+
// Mark unconditionally — the deferred truncation pass picks up
|
|
945
|
+
// marked nodes once a definite parent width is known (e.g. table
|
|
946
|
+
// cells whose column-alignment runs in phase 3).
|
|
947
|
+
const maxLines = context.textMaxLines || 1;
|
|
948
|
+
markForTruncation(textNode, maxLines);
|
|
949
|
+
if (context.maxWidth) {
|
|
950
|
+
applyTruncation(textNode, context.maxWidth, maxLines, context.textLineHeight);
|
|
947
951
|
}
|
|
948
|
-
}
|
|
952
|
+
}
|
|
953
|
+
if (!context.textTruncate && context.maxWidth && context.parentLayout !== 'HORIZONTAL') {
|
|
949
954
|
try {
|
|
950
955
|
textNode.textAutoResize = 'HEIGHT';
|
|
951
956
|
textNode.resize(context.maxWidth, textNode.height);
|
|
@@ -1283,17 +1288,12 @@ function buildFigmaNode(
|
|
|
1283
1288
|
? Math.min(layoutComputed.maxWidth, context.maxWidth)
|
|
1284
1289
|
: layoutComputed.maxWidth;
|
|
1285
1290
|
}
|
|
1286
|
-
// Detect text truncation from classes on this element; reset per-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
for (const cls of classes) {
|
|
1293
|
-
const m = cls.match(/^line-clamp-(\d+)$/);
|
|
1294
|
-
if (m) { _truncate = true; _maxLines = parseInt(m[1], 10); break; }
|
|
1295
|
-
}
|
|
1296
|
-
}
|
|
1291
|
+
// Detect text truncation from classes on this element; reset per-
|
|
1292
|
+
// element (don't inherit). Parsing is centralised in
|
|
1293
|
+
// `getTruncateFromClasses` so all text-creation sites stay in sync.
|
|
1294
|
+
const _truncateInfo = getTruncateFromClasses(classes);
|
|
1295
|
+
const _truncate: boolean | undefined = _truncateInfo ? true : undefined;
|
|
1296
|
+
const _maxLines: number | undefined = _truncateInfo ? _truncateInfo.maxLines : undefined;
|
|
1297
1297
|
const nextContext: RenderContext = {
|
|
1298
1298
|
...context,
|
|
1299
1299
|
textAlign: alignFromClasses || context.textAlign,
|
|
@@ -1340,10 +1340,35 @@ function buildFigmaNode(
|
|
|
1340
1340
|
nextContext.selectHighlightedValue = !nextContext.selectSelectedValue && nextContext.selectIsOpen
|
|
1341
1341
|
? findFirstSelectItemValue(node)
|
|
1342
1342
|
: null;
|
|
1343
|
-
} else if (isSelectContentTag(node.tagName)) {
|
|
1344
|
-
//
|
|
1345
|
-
//
|
|
1346
|
-
|
|
1343
|
+
} else if (isSelectTriggerTag(node.tagName) || isSelectContentTag(node.tagName)) {
|
|
1344
|
+
// The scanner flattens transparent `<Select>` / `<SelectPrimitive.Root>`
|
|
1345
|
+
// wrappers (`propagateSelectRootContext` in component-scanner.ts) and
|
|
1346
|
+
// copies the wrapper's resolved context onto the hoisted Trigger and
|
|
1347
|
+
// Content as `__selectRoot*` data props. Bridge them into the render
|
|
1348
|
+
// context here so SelectValue / SelectItem children read the same
|
|
1349
|
+
// selectSelectedValue / selectSelectedLabel / selectIsOpen that the
|
|
1350
|
+
// explicit `isSelectRootTag` branch above would have set when the
|
|
1351
|
+
// wrapper was still in the tree.
|
|
1352
|
+
const annotatedSelectedValue = normalizeSelectableValue(node.props?.__selectRootSelectedValue);
|
|
1353
|
+
if (annotatedSelectedValue && !nextContext.selectSelectedValue) {
|
|
1354
|
+
nextContext.selectSelectedValue = annotatedSelectedValue;
|
|
1355
|
+
}
|
|
1356
|
+
const annotatedSelectedLabel = node.props?.__selectRootSelectedLabel;
|
|
1357
|
+
if (annotatedSelectedLabel && !nextContext.selectSelectedLabel) {
|
|
1358
|
+
nextContext.selectSelectedLabel = String(annotatedSelectedLabel);
|
|
1359
|
+
}
|
|
1360
|
+
if (!nextContext.selectIsOpen && isTruthyStateProp(node.props?.__selectRootIsOpen)) {
|
|
1361
|
+
nextContext.selectIsOpen = true;
|
|
1362
|
+
}
|
|
1363
|
+
const annotatedHighlight = normalizeSelectableValue(node.props?.__selectRootHighlightedValue);
|
|
1364
|
+
if (annotatedHighlight && !nextContext.selectHighlightedValue) {
|
|
1365
|
+
nextContext.selectHighlightedValue = annotatedHighlight;
|
|
1366
|
+
}
|
|
1367
|
+
if (isSelectContentTag(node.tagName)) {
|
|
1368
|
+
// Select popovers should size to their content in Figma previews instead of inheriting
|
|
1369
|
+
// the story wrapper max width (e.g. w-56), which causes label wrapping.
|
|
1370
|
+
nextContext.maxWidth = undefined;
|
|
1371
|
+
}
|
|
1347
1372
|
} else if (isSelectItemTag(node.tagName)) {
|
|
1348
1373
|
const itemValue = normalizeSelectableValue(node.props?.value);
|
|
1349
1374
|
const selected = resolveSelectItemSelected(node as NodeIRElement, context);
|
|
@@ -1366,6 +1391,41 @@ function buildFigmaNode(
|
|
|
1366
1391
|
if (isAccordionContentTag(node.tagName) && !context.accordionItemIsOpen) {
|
|
1367
1392
|
return null;
|
|
1368
1393
|
}
|
|
1394
|
+
// SelectContent (the dropdown items popover) renders only when the
|
|
1395
|
+
// Select is open. Without this gate, the items panel renders as a
|
|
1396
|
+
// phantom frame below the trigger in every Figma preview, even though
|
|
1397
|
+
// the Select is closed in the captured state. Matches the
|
|
1398
|
+
// AccordionContent / TabsContent gating above.
|
|
1399
|
+
//
|
|
1400
|
+
// The inherited `context.selectIsOpen` is set by `isSelectRootTag` on the
|
|
1401
|
+
// wrapper. After the scanner flattens the Select wrapper away, the open
|
|
1402
|
+
// signal lives on the Content node's own `__selectRootIsOpen` data prop —
|
|
1403
|
+
// check both so closed previews don't leak the dropdown either way.
|
|
1404
|
+
if (
|
|
1405
|
+
isSelectContentTag(node.tagName)
|
|
1406
|
+
&& !context.selectIsOpen
|
|
1407
|
+
&& !isTruthyStateProp(node.props?.__selectRootIsOpen)
|
|
1408
|
+
) {
|
|
1409
|
+
return null;
|
|
1410
|
+
}
|
|
1411
|
+
// DialogContent visibility is gated by the wrapping `<Dialog>` /
|
|
1412
|
+
// `<DialogPrimitive.Root>`'s `open` / `defaultOpen` prop. The scanner
|
|
1413
|
+
// annotates every Content with `__dialogRootIsOpen` derived from its
|
|
1414
|
+
// nearest Dialog Root (see `propagateDialogRootContext` in
|
|
1415
|
+
// component-scanner.ts). Closed previews — typically a help-info
|
|
1416
|
+
// Dialog nested inside another Dialog's header — would otherwise
|
|
1417
|
+
// render their content inline as a sliver next to the parent's
|
|
1418
|
+
// content (the "Open position / Overview" leak in
|
|
1419
|
+
// IncreasePositionModal). Annotation absent → falls back to render
|
|
1420
|
+
// (preserves behaviour for trees not yet covered by the scanner
|
|
1421
|
+
// transform).
|
|
1422
|
+
if (
|
|
1423
|
+
isDialogContentTag(node.tagName)
|
|
1424
|
+
&& node.props?.__dialogRootIsOpen != null
|
|
1425
|
+
&& !isTruthyStateProp(node.props.__dialogRootIsOpen)
|
|
1426
|
+
) {
|
|
1427
|
+
return null;
|
|
1428
|
+
}
|
|
1369
1429
|
if (isTabsContentTag(node.tagName)) {
|
|
1370
1430
|
const forceMount = isTruthyStateProp(node.props?.forceMount);
|
|
1371
1431
|
const active = resolveTabsNodeActive(node as NodeIRElement, context);
|
|
@@ -1374,16 +1434,14 @@ function buildFigmaNode(
|
|
|
1374
1434
|
}
|
|
1375
1435
|
}
|
|
1376
1436
|
|
|
1377
|
-
//
|
|
1378
|
-
//
|
|
1379
|
-
//
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
return null;
|
|
1386
|
-
}
|
|
1437
|
+
// Skip rendering when the resolved class list ends in `display: hidden`
|
|
1438
|
+
// or `sr-only` (with no later override). Sole source of truth is
|
|
1439
|
+
// `isEffectivelyHiddenOrSrOnly` — every other site that needs this
|
|
1440
|
+
// gate (collectTextContent, the renderChildren filter, inline-tree
|
|
1441
|
+
// collapse) routes through the same helper, so adding a new display
|
|
1442
|
+
// utility (e.g. tailwind v4 additions) lights up everywhere.
|
|
1443
|
+
if (isEffectivelyHiddenOrSrOnly(classes)) {
|
|
1444
|
+
return null;
|
|
1387
1445
|
}
|
|
1388
1446
|
|
|
1389
1447
|
if ((node.kind === 'component' || node.kind === 'element') && isSelectValueTag(node.tagName)) {
|
|
@@ -2042,9 +2100,13 @@ function buildFigmaNode(
|
|
|
2042
2100
|
const rest = cls.slice(3);
|
|
2043
2101
|
return !rest.startsWith('gradient') && !rest.startsWith('linear') && !rest.startsWith('radial') && rest !== 'transparent';
|
|
2044
2102
|
});
|
|
2045
|
-
|
|
2046
2103
|
if (inlineOnlyChildren && !hasLayoutClass && !hasFlexChildClass && !hasBgClass) {
|
|
2047
|
-
const
|
|
2104
|
+
const visibleInlineChildren = (node.children || []).filter((child) => {
|
|
2105
|
+
if (!('classes' in child) || !Array.isArray(child.classes)) return true;
|
|
2106
|
+
const resolved = resolveClassesForBreakpoint(child.classes, breakpointIndex);
|
|
2107
|
+
return !isEffectivelyHiddenOrSrOnly(resolved);
|
|
2108
|
+
});
|
|
2109
|
+
const inlineText = createInlineTextNode(node as NodeIRElement, textStyle, nextContext, colorGroup, theme, layoutComputed, visibleInlineChildren);
|
|
2048
2110
|
if (inlineText) return maybeWrapMxAuto(inlineText, classes, context);
|
|
2049
2111
|
}
|
|
2050
2112
|
|
|
@@ -2186,13 +2248,12 @@ function buildFigmaNode(
|
|
|
2186
2248
|
const textContent = collectTextContent(node);
|
|
2187
2249
|
if (textContent.trim()) {
|
|
2188
2250
|
const typo = TAG_TYPOGRAPHY[tagLower] || { fontSize: 14 };
|
|
2189
|
-
const isHeadingTag = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagLower);
|
|
2190
2251
|
const textOpts: CreateTextOptions & { theme: string } = {
|
|
2191
2252
|
fontSize: typo.fontSize,
|
|
2192
2253
|
bold: typo.bold,
|
|
2193
2254
|
lineHeight: typo.lineHeight,
|
|
2194
2255
|
theme,
|
|
2195
|
-
fontRole:
|
|
2256
|
+
fontRole: getFontRoleFromClasses(classes, tagLower),
|
|
2196
2257
|
};
|
|
2197
2258
|
if (textStyle.text) {
|
|
2198
2259
|
textOpts.fill = textStyle.text;
|
|
@@ -2226,7 +2287,19 @@ function buildFigmaNode(
|
|
|
2226
2287
|
// HORIZONTAL+CENTER (numbered-circle case) and HORIZONTAL+HUG
|
|
2227
2288
|
// (inline-flex pill case); resizes otherwise.
|
|
2228
2289
|
const textContentWidth = resolveTextResizeWidth(frame, nextContext.maxWidth);
|
|
2229
|
-
|
|
2290
|
+
// Text truncation: this frame-container path handles
|
|
2291
|
+
// text-only elements with sizing classes — e.g.
|
|
2292
|
+
// `<span block max-w-[9.5rem] truncate>`. Mark the text
|
|
2293
|
+
// unconditionally so a later deferred pass can still act,
|
|
2294
|
+
// and apply immediately if the element's own max-width gave
|
|
2295
|
+
// us a concrete content width.
|
|
2296
|
+
const truncateInfo = getTruncateFromClasses(classes);
|
|
2297
|
+
if (truncateInfo) {
|
|
2298
|
+
markForTruncation(textNode, truncateInfo.maxLines);
|
|
2299
|
+
if (textContentWidth != null) {
|
|
2300
|
+
applyTruncation(textNode, textContentWidth, truncateInfo.maxLines, textOpts.lineHeight);
|
|
2301
|
+
}
|
|
2302
|
+
} else if (textContentWidth != null) {
|
|
2230
2303
|
try {
|
|
2231
2304
|
textNode.textAutoResize = 'HEIGHT';
|
|
2232
2305
|
textNode.resize(textContentWidth, textNode.height);
|
|
@@ -2245,13 +2318,12 @@ function buildFigmaNode(
|
|
|
2245
2318
|
if (!textContent.trim()) return null;
|
|
2246
2319
|
|
|
2247
2320
|
const typo = TAG_TYPOGRAPHY[tagLower] || { fontSize: 14 };
|
|
2248
|
-
const isHeadingTag = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagLower);
|
|
2249
2321
|
const textOpts: CreateTextOptions & { theme: string } = {
|
|
2250
2322
|
fontSize: typo.fontSize,
|
|
2251
2323
|
bold: typo.bold,
|
|
2252
2324
|
lineHeight: typo.lineHeight,
|
|
2253
2325
|
theme,
|
|
2254
|
-
fontRole:
|
|
2326
|
+
fontRole: getFontRoleFromClasses(classes, tagLower),
|
|
2255
2327
|
};
|
|
2256
2328
|
|
|
2257
2329
|
// Extract text color from classes
|
|
@@ -2300,22 +2372,14 @@ function buildFigmaNode(
|
|
|
2300
2372
|
const shouldConstrain = effectiveMaxWidth
|
|
2301
2373
|
&& nextContext.parentLayout !== 'HORIZONTAL'
|
|
2302
2374
|
&& (isBlockText || classes.includes('block') || classes.includes('w-full'));
|
|
2303
|
-
const
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2375
|
+
const truncateInfo = getTruncateFromClasses(classes);
|
|
2376
|
+
if (truncateInfo) {
|
|
2377
|
+
markForTruncation(textNode, truncateInfo.maxLines);
|
|
2378
|
+
if (effectiveMaxWidth) {
|
|
2379
|
+
applyTruncation(textNode, effectiveMaxWidth, truncateInfo.maxLines, textOpts.lineHeight);
|
|
2380
|
+
}
|
|
2308
2381
|
}
|
|
2309
|
-
if (
|
|
2310
|
-
try {
|
|
2311
|
-
const maxLines = lineClampN ?? 1;
|
|
2312
|
-
const fs = textOpts.fontSize || 14;
|
|
2313
|
-
const lh = textOpts.lineHeight || Math.ceil(fs * 1.5);
|
|
2314
|
-
textNode.textTruncation = 'ENDING';
|
|
2315
|
-
textNode.maxLines = maxLines;
|
|
2316
|
-
textNode.resize(effectiveMaxWidth, Math.max(lh, lh * maxLines));
|
|
2317
|
-
} catch (_err) { /* ignore */ }
|
|
2318
|
-
} else if (shouldConstrain) {
|
|
2382
|
+
if (!truncateInfo && shouldConstrain) {
|
|
2319
2383
|
try {
|
|
2320
2384
|
textNode.textAutoResize = 'HEIGHT';
|
|
2321
2385
|
textNode.resize(effectiveMaxWidth, textNode.height);
|
|
@@ -2432,6 +2496,22 @@ function buildFigmaNode(
|
|
|
2432
2496
|
) {
|
|
2433
2497
|
frame.resize(context.maxWidth, frame.height);
|
|
2434
2498
|
frame.primaryAxisSizingMode = 'FIXED';
|
|
2499
|
+
} else if (
|
|
2500
|
+
frame.layoutMode === 'VERTICAL'
|
|
2501
|
+
&& context.maxWidth
|
|
2502
|
+
&& frame.layoutAlign === 'STRETCH'
|
|
2503
|
+
&& frame.counterAxisSizingMode !== 'FIXED'
|
|
2504
|
+
) {
|
|
2505
|
+
// Same as the text-element path: a VERTICAL frame that's
|
|
2506
|
+
// STRETCH-aligned into a known-width parent has a definite
|
|
2507
|
+
// counter-axis width. Lock counterAxisSizingMode='FIXED' so the
|
|
2508
|
+
// block-level children inside (e.g. a `<p>` description inside an
|
|
2509
|
+
// inner wrapper div) recognise their parent as definite-width and
|
|
2510
|
+
// get their own implicit STRETCH via `parentCounterFixed`. Without
|
|
2511
|
+
// this, the cascade stops one level deep and longer block siblings
|
|
2512
|
+
// get cropped to the wrapper's first non-stretch child's width.
|
|
2513
|
+
frame.resize(context.maxWidth, frame.height);
|
|
2514
|
+
frame.counterAxisSizingMode = 'FIXED';
|
|
2435
2515
|
}
|
|
2436
2516
|
|
|
2437
2517
|
const frameHasFixedWidth = frame.layoutMode === 'HORIZONTAL'
|
|
@@ -2471,7 +2551,22 @@ function buildFigmaNode(
|
|
|
2471
2551
|
textColor: nextContext.textColor,
|
|
2472
2552
|
textColorToken: nextContext.textColorToken,
|
|
2473
2553
|
};
|
|
2474
|
-
|
|
2554
|
+
// Drop children whose resolved class list at the current breakpoint
|
|
2555
|
+
// ends in `display: hidden`. The outer-element hidden gate at the top
|
|
2556
|
+
// of buildFigmaNode handles whole-element skip when buildFigmaNode is
|
|
2557
|
+
// recursively invoked, but the inline-text collapse path below
|
|
2558
|
+
// (`createInlineTextNode`) and the inline-segment collector
|
|
2559
|
+
// (`collectInlineSegments`) merge children's text content without
|
|
2560
|
+
// calling buildFigmaNode per child — so a `<span sm:hidden>A</span>
|
|
2561
|
+
// <span hidden sm:inline>B</span>` pair would both contribute text
|
|
2562
|
+
// and render as "A B" concatenated regardless of breakpoint. Pre-
|
|
2563
|
+
// filtering here ensures hidden children are dropped before either
|
|
2564
|
+
// the inline-collapse path or the per-child render loop runs.
|
|
2565
|
+
const renderChildren = getRenderableChildren(node, classes).filter((child) => {
|
|
2566
|
+
if (!('classes' in child) || !Array.isArray(child.classes)) return true;
|
|
2567
|
+
const resolved = resolveClassesForBreakpoint(child.classes, breakpointIndex);
|
|
2568
|
+
return !isEffectivelyHiddenOrSrOnly(resolved);
|
|
2569
|
+
});
|
|
2475
2570
|
const accordionItemOrdinal = { value: 0 };
|
|
2476
2571
|
|
|
2477
2572
|
// Convert child mt-* margins to auto-layout spacing in vertical stacks.
|
|
@@ -2485,7 +2580,7 @@ function buildFigmaNode(
|
|
|
2485
2580
|
&& renderChildren.length > 0
|
|
2486
2581
|
&& renderChildren.every(isInlineTextNode);
|
|
2487
2582
|
if (hasOnlyInlineChildren) {
|
|
2488
|
-
const inlineText = createInlineTextNode(node as NodeIRElement, textStyle, nextContext, colorGroup, theme, layoutComputed);
|
|
2583
|
+
const inlineText = createInlineTextNode(node as NodeIRElement, textStyle, nextContext, colorGroup, theme, layoutComputed, renderChildren);
|
|
2489
2584
|
if (inlineText) {
|
|
2490
2585
|
frame.appendChild(inlineText);
|
|
2491
2586
|
return maybeWrapMxAuto(frame, classes, context);
|
|
@@ -2561,10 +2656,43 @@ function buildFigmaNode(
|
|
|
2561
2656
|
// ignore if node cannot grow in this parent
|
|
2562
2657
|
}
|
|
2563
2658
|
}
|
|
2659
|
+
// Record the JSX `colSpan` on the rendered cell so the Phase-3
|
|
2660
|
+
// table-column-alignment pass can skip multi-cell rows when
|
|
2661
|
+
// computing per-column max width (an empty-state row with
|
|
2662
|
+
// `colSpan={7}` shouldn't dominate column 0). Default colspan
|
|
2663
|
+
// of 1 is recorded too, both for explicit-intent symmetry and
|
|
2664
|
+
// so the alignment pass doesn't need a "default = 1" branch.
|
|
2665
|
+
try {
|
|
2666
|
+
(childNode as SceneNode).setPluginData('inkbridge:col-span', String(colSpan));
|
|
2667
|
+
} catch (_err) {
|
|
2668
|
+
// ignore — plugin data write is best-effort
|
|
2669
|
+
}
|
|
2564
2670
|
}
|
|
2565
2671
|
}
|
|
2566
2672
|
}
|
|
2567
2673
|
|
|
2674
|
+
// Demote SPACE_BETWEEN → MIN when the frame ended up with fewer than 2
|
|
2675
|
+
// in-flow children. Figma centers the lone child of a SPACE_BETWEEN
|
|
2676
|
+
// frame ("Gap: Auto" in the inspector), which is not what CSS does
|
|
2677
|
+
// (`justify-content: space-between` with one item anchors to start).
|
|
2678
|
+
// Commonly hits patterns like
|
|
2679
|
+
// <header className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
|
2680
|
+
// <div>{title + description}</div>
|
|
2681
|
+
// {actions ? <div>{actions}</div> : null}
|
|
2682
|
+
// </header>
|
|
2683
|
+
// — at md+ the header flips HORIZONTAL with SPACE_BETWEEN. When the
|
|
2684
|
+
// consumer doesn't pass `actions`, only the title-content div remains
|
|
2685
|
+
// and Figma centers it horizontally. Demoting here restores the
|
|
2686
|
+
// CSS-correct start-anchor.
|
|
2687
|
+
if (
|
|
2688
|
+
'primaryAxisAlignItems' in frame
|
|
2689
|
+
&& frame.primaryAxisAlignItems === 'SPACE_BETWEEN'
|
|
2690
|
+
&& Array.isArray(frame.children)
|
|
2691
|
+
&& frame.children.filter((c) => !('layoutPositioning' in c) || c.layoutPositioning !== 'ABSOLUTE').length < 2
|
|
2692
|
+
) {
|
|
2693
|
+
try { frame.primaryAxisAlignItems = 'MIN'; } catch (_err) { /* ignore */ }
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2568
2696
|
// Resolve any deferred percent positions on this frame's direct children
|
|
2569
2697
|
// now that all of them are appended and (most likely) sized.
|
|
2570
2698
|
if ('layoutMode' in frame) {
|
|
@@ -747,6 +747,14 @@ export function flattenSvgNode(node: SceneNode): VectorNode | null {
|
|
|
747
747
|
}
|
|
748
748
|
|
|
749
749
|
function resizeNode(node: SceneNode, width: number, height: number): void {
|
|
750
|
+
// Figma VECTOR nodes reject 0-dim resize with
|
|
751
|
+
// "Cannot set width and height of vector node to zero". The outer
|
|
752
|
+
// `resizeSvgNodeTo` already guards function-level w/h, but the
|
|
753
|
+
// per-sub-vector loop computes `vector.width * sx` per child and can
|
|
754
|
+
// land on 0 when a sub-vector has zero base dimensions (e.g. an empty
|
|
755
|
+
// path, or a marker that didn't pick up bounds from `createNodeFromSvg`).
|
|
756
|
+
// Mirror the outer guard at the call site so any caller benefits.
|
|
757
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return;
|
|
750
758
|
if ('resizeWithoutConstraints' in node) node.resizeWithoutConstraints(width, height);
|
|
751
759
|
else if ('resize' in node) node.resize(width, height);
|
|
752
760
|
}
|
|
@@ -432,11 +432,124 @@ function flattenScrollAreaSubtree(node: NodeIR): NodeIR[] | null {
|
|
|
432
432
|
changed = true;
|
|
433
433
|
continue;
|
|
434
434
|
}
|
|
435
|
+
// ScrollAreaPrimitive.Root wrapping a `<table>`: replace the
|
|
436
|
+
// component wrapper with a plain `<div>` that inherits Root's
|
|
437
|
+
// classes (including the consumer's `px-* sm:pl-* sm:pr-*` padding
|
|
438
|
+
// passed via ScrollArea's `className` prop). Two wins:
|
|
439
|
+
// 1. The synthetic `div` is `kind: 'element'`, which gets the
|
|
440
|
+
// normal block-stretch-to-parent-width treatment in
|
|
441
|
+
// ui-builder; `Root` was `kind: 'component'` and stayed
|
|
442
|
+
// HUG-primary, leaving the table narrower than the card.
|
|
443
|
+
// 2. The padding the consumer wrote on `<ScrollArea>` is
|
|
444
|
+
// preserved verbatim — `px-4` / `sm:pl-3 sm:pr-0` keep their
|
|
445
|
+
// visual effect even though the scroll mechanism itself is
|
|
446
|
+
// meaningless in static Figma renders.
|
|
447
|
+
// ScrollArea around non-table content is left untouched (case
|
|
448
|
+
// (i1) above) — only the table case re-shapes the wrapper.
|
|
449
|
+
if (tag === 'ScrollAreaPrimitive.Root' && subtreeContainsTable(child)) {
|
|
450
|
+
const inner = flattenScrollAreaSubtree(child)
|
|
451
|
+
?? (('children' in child && Array.isArray(child.children)) ? child.children : []);
|
|
452
|
+
const rawRootClasses = ('classes' in child && Array.isArray((child as { classes?: string[] }).classes))
|
|
453
|
+
? ((child as { classes: string[] }).classes)
|
|
454
|
+
: [];
|
|
455
|
+
// Normalise the consumer's scrollbar-gutter trick: `pl-N pr-0` is
|
|
456
|
+
// common shadcn shorthand for "leave room for the browser
|
|
457
|
+
// scrollbar on the right". Since Figma has no scrollbar (the
|
|
458
|
+
// ScrollArea wrapper is dropped above), that asymmetry just
|
|
459
|
+
// leaves table rows extending all the way to the card's right
|
|
460
|
+
// edge with no visual padding. Mirror `pr-0` to match the same
|
|
461
|
+
// breakpoint's `pl-N` so the rendered cell looks balanced.
|
|
462
|
+
const rootClasses = mirrorScrollbarGutterPadding(rawRootClasses);
|
|
463
|
+
const rootProps = ('props' in child && (child as { props?: Record<string, unknown> }).props)
|
|
464
|
+
? ((child as { props: Record<string, unknown> }).props)
|
|
465
|
+
: {};
|
|
466
|
+
next.push({
|
|
467
|
+
kind: 'element',
|
|
468
|
+
tagName: 'div',
|
|
469
|
+
tagLower: 'div',
|
|
470
|
+
props: rootProps,
|
|
471
|
+
classes: rootClasses,
|
|
472
|
+
children: inner,
|
|
473
|
+
} as NodeIR);
|
|
474
|
+
changed = true;
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
435
477
|
next.push(child);
|
|
436
478
|
}
|
|
437
479
|
return changed ? next : null;
|
|
438
480
|
}
|
|
439
481
|
|
|
482
|
+
/**
|
|
483
|
+
* Does this node's subtree contain a semantic `<table>` element? Gates
|
|
484
|
+
* the `ScrollAreaPrimitive.Root` → synthetic `<div>` swap above. A
|
|
485
|
+
* `ScrollArea` around non-table content keeps its Root wrapper so
|
|
486
|
+
* standalone `<ScrollArea>` stories don't lose their structural shape.
|
|
487
|
+
*/
|
|
488
|
+
/**
|
|
489
|
+
* Mirror the scrollbar-gutter trick when stripping a ScrollArea
|
|
490
|
+
* around a table. CSS pattern `pl-N pr-0` (with optional `<variant>:`
|
|
491
|
+
* prefix) typically means "give the browser scrollbar the right
|
|
492
|
+
* gutter visually". In Figma there's no scrollbar to consume the
|
|
493
|
+
* gap, so the row separators run flush to the card edge. We swap
|
|
494
|
+
* `pr-0` for `pr-N` (same N as the matching pl) at the same
|
|
495
|
+
* variant; pr-0 with no matching pl in that variant is left alone
|
|
496
|
+
* (could be a genuinely-edge-aligned design).
|
|
497
|
+
*
|
|
498
|
+
* Pure function on a class string array. Variant detection uses the
|
|
499
|
+
* standard `:` separator and the last segment as the utility.
|
|
500
|
+
*/
|
|
501
|
+
function mirrorScrollbarGutterPadding(classes: string[]): string[] {
|
|
502
|
+
if (!classes || classes.length === 0) return classes;
|
|
503
|
+
|
|
504
|
+
function splitVariant(cls: string): { variant: string; base: string } {
|
|
505
|
+
const idx = cls.lastIndexOf(':');
|
|
506
|
+
if (idx === -1) return { variant: '', base: cls };
|
|
507
|
+
return { variant: cls.slice(0, idx + 1), base: cls.slice(idx + 1) };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Build a map: variant → pl-N value (string after `pl-`).
|
|
511
|
+
// Also handle `px-N` → contributes pl-N for the same variant.
|
|
512
|
+
const plByVariant: Record<string, string> = {};
|
|
513
|
+
for (let i = 0; i < classes.length; i++) {
|
|
514
|
+
const { variant, base } = splitVariant(classes[i]);
|
|
515
|
+
if (base.indexOf('pl-') === 0) {
|
|
516
|
+
plByVariant[variant] = base.slice(3);
|
|
517
|
+
} else if (base.indexOf('px-') === 0) {
|
|
518
|
+
// `px-N` only acts as a fallback pl source — if a more specific
|
|
519
|
+
// `pl-N` exists in the same variant, the later loop iteration
|
|
520
|
+
// wins. We don't overwrite px-derived entries with px-derived
|
|
521
|
+
// ones from a different ordering, but that's fine: px is
|
|
522
|
+
// symmetric, so order doesn't matter.
|
|
523
|
+
if (plByVariant[variant] == null) plByVariant[variant] = base.slice(3);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
let mutated = false;
|
|
528
|
+
const out: string[] = [];
|
|
529
|
+
for (let i = 0; i < classes.length; i++) {
|
|
530
|
+
const cls = classes[i];
|
|
531
|
+
const { variant, base } = splitVariant(cls);
|
|
532
|
+
if (base === 'pr-0' && plByVariant[variant] != null) {
|
|
533
|
+
out.push(variant + 'pr-' + plByVariant[variant]);
|
|
534
|
+
mutated = true;
|
|
535
|
+
} else {
|
|
536
|
+
out.push(cls);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return mutated ? out : classes;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function subtreeContainsTable(node: NodeIR): boolean {
|
|
543
|
+
if (!isClassedElement(node)) return false;
|
|
544
|
+
const tag = (node as { tagName?: string }).tagName;
|
|
545
|
+
if (typeof tag === 'string' && tag.toLowerCase() === 'table') return true;
|
|
546
|
+
if (!('children' in node) || !Array.isArray(node.children)) return false;
|
|
547
|
+
for (const c of node.children) {
|
|
548
|
+
if (subtreeContainsTable(c)) return true;
|
|
549
|
+
}
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
|
|
440
553
|
/**
|
|
441
554
|
* Recurse the IR, injecting registry classes for any element whose
|
|
442
555
|
* `data-slot` matches a known shadcn pattern AND applying shadcn-specific
|
package/src/github/github.ts
CHANGED
|
@@ -178,7 +178,19 @@ function requireResponseString(value: unknown, context: string): string {
|
|
|
178
178
|
throw new Error('GitHub API response missing ' + context);
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
-
function buildPatchEndpointCandidates(): string[] {
|
|
181
|
+
function buildPatchEndpointCandidates(devServerPort?: string): string[] {
|
|
182
|
+
// When `devServerPort` is configured, collapse the candidate list to
|
|
183
|
+
// that single port — same pattern as the pack-fetch candidates. The
|
|
184
|
+
// value is already validated to a numeric 1-65535 by
|
|
185
|
+
// `normalizeDevServerPort` on the config layer, but we re-validate
|
|
186
|
+
// here defensively so callers can pass through unvalidated strings.
|
|
187
|
+
const trimmed = (devServerPort || '').trim();
|
|
188
|
+
if (trimmed) {
|
|
189
|
+
const n = parseInt(trimmed, 10);
|
|
190
|
+
if (Number.isFinite(n) && n >= 1 && n <= 65535) {
|
|
191
|
+
return ['http://localhost:' + n + PATCH_ENDPOINT_PATH];
|
|
192
|
+
}
|
|
193
|
+
}
|
|
182
194
|
const out: string[] = [];
|
|
183
195
|
for (let i = 0; i < PATCH_ENDPOINT_PORTS.length; i++) {
|
|
184
196
|
out.push('http://localhost:' + PATCH_ENDPOINT_PORTS[i] + PATCH_ENDPOINT_PATH);
|
|
@@ -201,7 +213,8 @@ function serializeCssUpdates(updatesByTheme: Map<string, Map<string, string>>):
|
|
|
201
213
|
async function patchCssViaDevServer(
|
|
202
214
|
cssPath: string,
|
|
203
215
|
cssText: string,
|
|
204
|
-
updatesByTheme: Map<string, Map<string, string
|
|
216
|
+
updatesByTheme: Map<string, Map<string, string>>,
|
|
217
|
+
devServerPort?: string
|
|
205
218
|
): Promise<string | null> {
|
|
206
219
|
await waitForUIReady();
|
|
207
220
|
|
|
@@ -218,7 +231,7 @@ async function patchCssViaDevServer(
|
|
|
218
231
|
figma.ui.postMessage({
|
|
219
232
|
type: 'patch-css',
|
|
220
233
|
requestId,
|
|
221
|
-
candidates: buildPatchEndpointCandidates(),
|
|
234
|
+
candidates: buildPatchEndpointCandidates(devServerPort),
|
|
222
235
|
payload: {
|
|
223
236
|
filePath: cssPath,
|
|
224
237
|
cssText,
|
|
@@ -1039,7 +1052,12 @@ async function buildTokenCommitPlan(token: string): Promise<TokenCommitPlan> {
|
|
|
1039
1052
|
const updates = buildCssVariableUpdates(variableTokenPatch || ({} as Tokens), tokensBeforeVariablePatch);
|
|
1040
1053
|
const fullThemeUpdates = buildCssVariableUpdates(workingTokens, tokensBeforeVariablePatch);
|
|
1041
1054
|
const activeThemeNames = getActiveThemeNames(workingTokens);
|
|
1042
|
-
let patchedCss = await patchCssViaDevServer(
|
|
1055
|
+
let patchedCss = await patchCssViaDevServer(
|
|
1056
|
+
cssFile.path,
|
|
1057
|
+
cssFile.content,
|
|
1058
|
+
updates,
|
|
1059
|
+
GITHUB_CONFIG!.devServerPort,
|
|
1060
|
+
);
|
|
1043
1061
|
if (typeof patchedCss !== 'string') {
|
|
1044
1062
|
// Dev server route unavailable or invalid response: fallback to in-plugin patcher.
|
|
1045
1063
|
patchedCss = patchCssVariables(cssFile.content, updates, activeThemeNames, fullThemeUpdates);
|
package/src/layout/index.ts
CHANGED
|
@@ -18,6 +18,10 @@ export {
|
|
|
18
18
|
export type { LayoutIR, SizingMode } from './parser';
|
|
19
19
|
export * from './width-solver';
|
|
20
20
|
export * from './deferred-layout';
|
|
21
|
+
export * from './intrinsic-sizing';
|
|
22
|
+
export * from './intrinsic-applier';
|
|
23
|
+
export * from './table-layout';
|
|
24
|
+
export * from './text-truncate-pass';
|
|
21
25
|
export * from './layout-utils';
|
|
22
26
|
export * from './size-utils';
|
|
23
27
|
export * from './ring-utils';
|