inkbridge 0.1.0-beta.2 → 0.1.0-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +108 -25
- package/bin/inkbridge.mjs +354 -83
- package/code.js +40 -11802
- package/manifest.json +1 -0
- package/package.json +74 -23
- package/scanner/adapter-utils-regression.ts +159 -0
- package/scanner/aspect-percent-position-regression.ts +237 -0
- package/scanner/aspect-ratio-regression.ts +90 -0
- package/scanner/blob-placement-regression.ts +2 -2
- package/scanner/block-cache-regression.ts +195 -0
- package/scanner/bundle-size-regression.ts +50 -0
- package/scanner/child-sizing-matrix-regression.ts +303 -0
- package/scanner/cli.ts +342 -13
- package/scanner/component-scanner.ts +2108 -174
- package/scanner/component-sections-regression.ts +198 -0
- package/scanner/compound-classes-lookup-regression.ts +163 -0
- package/scanner/css-token-reader-regression.ts +7 -6
- package/scanner/css-token-reader.ts +152 -31
- package/scanner/cva-jsx-child-fallback-regression.ts +98 -0
- package/scanner/cva-master-icon-regression.ts +315 -0
- package/scanner/data-attr-prop-alias-regression.ts +129 -0
- package/scanner/explicit-size-root-regression.ts +102 -0
- package/scanner/font-family-extract-regression.ts +113 -0
- package/scanner/font-style-resolver-regression.ts +1 -1
- package/scanner/framework-adapter-shadcn-regression.ts +480 -0
- package/scanner/full-width-matrix-regression.ts +338 -0
- package/scanner/grid-cols-extraction-regression.ts +110 -0
- package/scanner/image-src-collector-regression.ts +204 -0
- package/scanner/inline-flex-regression.ts +235 -0
- package/scanner/input-range-regression.ts +217 -0
- package/scanner/instance-rendering-regression.ts +224 -0
- package/scanner/jsx-prop-unresolved-regression.ts +178 -0
- package/scanner/jsx-text-regression.ts +178 -0
- package/scanner/layout-alignment-regression.ts +108 -0
- package/scanner/layout-flex-regression.ts +90 -0
- package/scanner/layout-mode-regression.ts +71 -0
- package/scanner/layout-sizing-regression.ts +227 -0
- package/scanner/layout-spacing-regression.ts +135 -0
- package/scanner/local-const-className-regression.ts +331 -0
- package/scanner/percent-position-regression.ts +105 -0
- package/scanner/provider-cascade-regression.ts +224 -0
- package/scanner/provider-flatten-regression.ts +235 -0
- package/scanner/radial-gradient-regression.ts +1 -1
- package/scanner/render-prop-parser-regression.ts +161 -0
- package/scanner/ring-utility-regression.ts +153 -0
- package/scanner/sandbox-spread-regression.ts +125 -0
- package/scanner/selection-pressed-regression.ts +241 -0
- package/scanner/size-full-normalization-regression.ts +127 -0
- package/scanner/state-classification-regression.ts +175 -0
- package/scanner/story-diagnostics-regression.ts +216 -0
- package/scanner/story-dimensioning-regression.ts +298 -0
- package/scanner/story-render-strategy-regression.ts +205 -0
- package/scanner/stretch-to-parent-width-regression.ts +147 -0
- package/scanner/svg-fill-parent-regression.ts +98 -0
- package/scanner/svg-group-inheritance-regression.ts +166 -0
- package/scanner/svg-marker-inline-regression.ts +211 -0
- package/scanner/svg-marker-regression.ts +116 -0
- package/scanner/tailwind-parser.ts +46 -4
- package/scanner/text-resize-matrix-regression.ts +173 -0
- package/scanner/transform-math-regression.ts +1 -1
- package/scanner/types.ts +26 -2
- package/src/cache/frame-cache.ts +150 -0
- package/src/cache/index.ts +2 -0
- package/src/{component-defs.ts → components/component-defs.ts} +25 -10
- package/src/{component-gen.ts → components/component-gen.ts} +43 -116
- package/src/components/component-instance.ts +386 -0
- package/src/components/component-library.ts +44 -0
- package/src/components/component-lookup.ts +161 -0
- package/src/components/index.ts +7 -0
- package/src/components/scanner-types.ts +39 -0
- package/src/components/symbol-instance-policy.ts +312 -0
- package/src/design-system/block-cache.ts +130 -0
- package/src/design-system/component-sections.ts +107 -0
- package/src/design-system/cva-inference.ts +187 -0
- package/src/design-system/cva-master.ts +427 -0
- package/src/design-system/cva-utils.ts +29 -0
- package/src/design-system/design-system.ts +334 -0
- package/src/design-system/frame-stabilizers.ts +191 -0
- package/src/design-system/frame-utils.ts +46 -0
- package/src/design-system/generated-node.ts +84 -0
- package/src/design-system/icon-rendering.ts +229 -0
- package/src/design-system/index.ts +13 -0
- package/src/design-system/instance-rendering.ts +307 -0
- package/src/design-system/master-shared.ts +133 -0
- package/src/design-system/node-helpers.ts +237 -0
- package/src/design-system/node-variants.ts +196 -0
- package/src/design-system/non-cva-master.ts +104 -0
- package/src/design-system/portal-handling.ts +138 -0
- package/src/design-system/preview-builder.ts +738 -0
- package/src/{render-context.ts → design-system/render-context.ts} +32 -6
- package/src/design-system/render-prop-parser.ts +50 -0
- package/src/design-system/responsive-resolver.ts +180 -0
- package/src/design-system/selectable-state.ts +157 -0
- package/src/design-system/state-master.ts +267 -0
- package/src/design-system/state-utils.ts +15 -0
- package/src/design-system/story-builder-context.ts +40 -0
- package/src/design-system/story-builder.ts +1322 -0
- package/src/design-system/story-diagnostics.ts +80 -0
- package/src/design-system/story-dimensioning.ts +272 -0
- package/src/design-system/story-frames.ts +400 -0
- package/src/design-system/story-instance.ts +333 -0
- package/src/{story-layout.ts → design-system/story-layout.ts} +2 -2
- package/src/design-system/story-render-strategy.ts +150 -0
- package/src/design-system/story-tree-search.ts +110 -0
- package/src/design-system/symbol-fallback.ts +89 -0
- package/src/design-system/symbol-source.ts +172 -0
- package/src/design-system/table-helpers.ts +56 -0
- package/src/design-system/tag-predicates.ts +99 -0
- package/src/design-system/theme-context.ts +52 -0
- package/src/design-system/typography.ts +100 -0
- package/src/design-system/ui-builder.ts +2676 -0
- package/src/{clip-path-decorative.ts → effects/clip-path-decorative.ts} +11 -11
- package/src/effects/icon-builder.ts +1074 -0
- package/src/effects/index.ts +5 -0
- package/src/effects/portal-panel.ts +369 -0
- package/src/{radial-gradient.ts → effects/radial-gradient.ts} +1 -1
- package/src/framework-adapters/index.ts +47 -0
- package/src/framework-adapters/shadcn.ts +541 -0
- package/src/{github.ts → github/github.ts} +46 -21
- package/src/github/index.ts +1 -0
- package/src/layout/deferred-layout.ts +1556 -0
- package/src/layout/index.ts +24 -0
- package/src/layout/layout-parser.ts +375 -0
- package/src/{layout-utils.ts → layout/layout-utils.ts} +23 -17
- package/src/layout/parser/alignment.ts +54 -0
- package/src/layout/parser/flex.ts +59 -0
- package/src/layout/parser/index.ts +65 -0
- package/src/layout/parser/ir.ts +80 -0
- package/src/layout/parser/layout-mode.ts +57 -0
- package/src/layout/parser/sizing.ts +241 -0
- package/src/layout/parser/spacing-scale.ts +78 -0
- package/src/layout/parser/spacing.ts +134 -0
- package/src/layout/ring-utils.ts +120 -0
- package/src/layout/size-utils.ts +143 -0
- package/src/layout/text-resize-decision.ts +51 -0
- package/src/{width-solver.ts → layout/width-solver.ts} +168 -37
- package/src/main.ts +444 -162
- package/src/{config.ts → plugin/config.ts} +12 -12
- package/src/{dev-server.ts → plugin/dev-server.ts} +3 -3
- package/src/plugin/image-src-collector.ts +52 -0
- package/src/plugin/index.ts +3 -0
- package/src/plugin/packs/index.ts +2 -0
- package/src/{pack-provider.ts → plugin/packs/pack-provider.ts} +12 -12
- package/src/{packs.ts → plugin/packs/packs.ts} +22 -17
- package/src/render-engine-version.ts +2 -0
- package/src/tailwind/adapter-utils.ts +137 -0
- package/src/{class-utils.ts → tailwind/class-utils.ts} +33 -6
- package/src/tailwind/index.ts +8 -0
- package/src/tailwind/jsx-utils.ts +319 -0
- package/src/{node-ir.ts → tailwind/node-ir.ts} +208 -19
- package/src/{responsive-analyzer.ts → tailwind/responsive-analyzer.ts} +32 -2
- package/src/{state-analyzer.ts → tailwind/state-analyzer.ts} +71 -5
- package/src/{tailwind.ts → tailwind/tailwind.ts} +423 -674
- package/src/{utility-resolver.ts → tailwind/utility-resolver.ts} +27 -6
- package/src/{font-style-resolver.ts → text/font-style-resolver.ts} +0 -2
- package/src/text/index.ts +4 -0
- package/src/{inline-text.ts → text/inline-text.ts} +13 -13
- package/src/{text-builder.ts → text/text-builder.ts} +24 -7
- package/src/{text-line.ts → text/text-line.ts} +2 -2
- package/src/{change-detection.ts → tokens/change-detection.ts} +12 -12
- package/src/{color-resolver.ts → tokens/color-resolver.ts} +1 -6
- package/src/{colors.ts → tokens/colors.ts} +13 -6
- package/src/tokens/index.ts +6 -0
- package/src/{token-source.ts → tokens/token-source.ts} +4 -1
- package/src/{tokens.ts → tokens/tokens.ts} +116 -20
- package/src/{variables.ts → tokens/variables.ts} +447 -102
- package/templates/patch-tokens-route.ts +25 -6
- package/templates/scan-components-route.ts +26 -5
- package/ui.html +485 -37
- package/src/component-lookup.ts +0 -82
- package/src/design-system.ts +0 -59
- package/src/icon-builder.ts +0 -607
- package/src/layout-parser.ts +0 -667
- package/src/story-builder.ts +0 -1706
- package/src/ui-builder.ts +0 -1996
- /package/src/{image-cache.ts → cache/image-cache.ts} +0 -0
- /package/src/{blob-placement.ts → effects/blob-placement.ts} +0 -0
- /package/src/{transform-math.ts → tailwind/transform-math.ts} +0 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
|
|
3
|
+
import { splitClassName } from '../src/tailwind';
|
|
4
|
+
import { LayoutParser } from '../src/layout/layout-parser';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Regression: `size-full` is Tailwind shorthand for `w-full h-full`. The
|
|
8
|
+
* plugin had separate code paths for `w-full` and `h-full` (each correctly
|
|
9
|
+
* marks the node for the deferred-layout stretch pipeline), but `size-full`
|
|
10
|
+
* was silently dropped — no sizing parser, no full-width/height mark, no
|
|
11
|
+
* STRETCH alignment.
|
|
12
|
+
*
|
|
13
|
+
* Symptom: `<AvatarFallback className="flex size-full ...">` rendered at
|
|
14
|
+
* its text-content size (24×19 — just "CN") instead of filling its
|
|
15
|
+
* 40×40 Avatar.Root parent. `<AvatarImage className="size-full">` rendered
|
|
16
|
+
* as a 200×150 placeholder. Both look broken next to a Storybook that
|
|
17
|
+
* happily rendered the same primitives correctly.
|
|
18
|
+
*
|
|
19
|
+
* Architectural fix: normalize `size-full` into `w-full h-full` at the
|
|
20
|
+
* `splitClassName` boundary — the single bottleneck every downstream
|
|
21
|
+
* parser passes through. Every existing w-full / h-full handler now
|
|
22
|
+
* applies to size-full automatically with zero parallel code paths. The
|
|
23
|
+
* numeric `size-N` form has its own dedicated handler in sizing.ts and is
|
|
24
|
+
* intentionally left as-is (it sets `widthMode`/`heightMode` to FIXED in
|
|
25
|
+
* one go, not via the FILL cascade).
|
|
26
|
+
*
|
|
27
|
+
* This file locks the boundary so a refactor of splitClassName can't
|
|
28
|
+
* regress this without breaking the test.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// splitClassName — size-full expands to w-full + h-full
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
{
|
|
36
|
+
assert.deepEqual(
|
|
37
|
+
splitClassName('size-full'),
|
|
38
|
+
['w-full', 'h-full'],
|
|
39
|
+
'bare size-full expands into [w-full, h-full]',
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
{
|
|
44
|
+
// In context with siblings — order preserved, expansion happens in-place.
|
|
45
|
+
assert.deepEqual(
|
|
46
|
+
splitClassName('flex size-full items-center'),
|
|
47
|
+
['flex', 'w-full', 'h-full', 'items-center'],
|
|
48
|
+
'size-full expanded in-place; siblings keep order',
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
{
|
|
53
|
+
// size-N (numeric) — NOT expanded. The numeric handler in sizing.ts
|
|
54
|
+
// sets both modes FIXED in one go; turning it into `w-N h-N` would
|
|
55
|
+
// route through a different (and slower) code path.
|
|
56
|
+
assert.deepEqual(
|
|
57
|
+
splitClassName('size-10'),
|
|
58
|
+
['size-10'],
|
|
59
|
+
'numeric size-N is NOT expanded — its FIXED handler stays the source of truth',
|
|
60
|
+
);
|
|
61
|
+
assert.deepEqual(
|
|
62
|
+
splitClassName('size-4 size-8 size-[40px]'),
|
|
63
|
+
['size-4', 'size-8', 'size-[40px]'],
|
|
64
|
+
'numeric / arbitrary size-* forms are left intact',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
{
|
|
69
|
+
// Important-modifier handling still strips ! around the token.
|
|
70
|
+
assert.deepEqual(
|
|
71
|
+
splitClassName('!size-full'),
|
|
72
|
+
['w-full', 'h-full'],
|
|
73
|
+
'leading-! is stripped before expansion (so !size-full still expands)',
|
|
74
|
+
);
|
|
75
|
+
assert.deepEqual(
|
|
76
|
+
splitClassName('size-full!'),
|
|
77
|
+
['w-full', 'h-full'],
|
|
78
|
+
'trailing-! is stripped before expansion',
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
{
|
|
83
|
+
// Empty / whitespace inputs are no-ops.
|
|
84
|
+
assert.deepEqual(splitClassName(''), [], 'empty string → []');
|
|
85
|
+
assert.deepEqual(splitClassName(' '), [], 'whitespace-only → []');
|
|
86
|
+
assert.deepEqual(splitClassName(undefined), [], 'undefined → []');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// LayoutParser — size-full now drives FILL on both axes
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
{
|
|
94
|
+
// After splitClassName expansion, the parser sees w-full h-full and sets
|
|
95
|
+
// both axes to FILL. This is what makes AvatarFallback fill its parent
|
|
96
|
+
// rather than hug its text content.
|
|
97
|
+
const ir = LayoutParser.parseToIR(splitClassName('flex size-full'));
|
|
98
|
+
assert.equal(
|
|
99
|
+
ir.widthMode,
|
|
100
|
+
'FILL',
|
|
101
|
+
'size-full sets widthMode=FILL via expansion → w-full handler',
|
|
102
|
+
);
|
|
103
|
+
assert.equal(
|
|
104
|
+
ir.heightMode,
|
|
105
|
+
'FILL',
|
|
106
|
+
'size-full sets heightMode=FILL via expansion → h-full handler',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
{
|
|
111
|
+
// Sanity: plain w-full alone only fills the width axis.
|
|
112
|
+
const ir = LayoutParser.parseToIR(splitClassName('w-full'));
|
|
113
|
+
assert.equal(ir.widthMode, 'FILL', 'w-full alone fills width only');
|
|
114
|
+
assert.notEqual(ir.heightMode, 'FILL', 'w-full alone does NOT fill height');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
{
|
|
118
|
+
// And numeric size still produces FIXED on both axes — the parser-level
|
|
119
|
+
// contract we don't want to drift.
|
|
120
|
+
const ir = LayoutParser.parseToIR(splitClassName('size-10'));
|
|
121
|
+
assert.equal(ir.widthMode, 'FIXED', 'size-10 → widthMode=FIXED');
|
|
122
|
+
assert.equal(ir.heightMode, 'FIXED', 'size-10 → heightMode=FIXED');
|
|
123
|
+
assert.equal(ir.fixedWidth, 40, 'size-10 → fixedWidth=40px');
|
|
124
|
+
assert.equal(ir.fixedHeight, 40, 'size-10 → fixedHeight=40px');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
console.log('size-full-normalization-regression: PASS (7 split cases + 3 IR cases)');
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { ComponentScanner } from './component-scanner';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression: marketing molecules whose JSX root is a wrapper component
|
|
7
|
+
* (e.g. `<Hero>...</Hero>`) — including ones that expand into a fragment
|
|
8
|
+
* `<>...</>` — must not be classified as `state` components even if a
|
|
9
|
+
* deeply-nested element carries hover/focus classes (typical in chips,
|
|
10
|
+
* links, or buttons inside the molecule).
|
|
11
|
+
*
|
|
12
|
+
* History: this same logical hole has caused HeroSection to be wrongly
|
|
13
|
+
* rendered as a State Matrix + symbol multiple times. Each time a fix
|
|
14
|
+
* landed in `hasRootStateModifier` it covered the case the regression
|
|
15
|
+
* was reported with, but missed the next variant — fragment-rooted
|
|
16
|
+
* expansion was the most recent miss. Lock it in.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// Test-only view of ComponentScanner that surfaces the otherwise-private
|
|
20
|
+
// `project` field and `analyzeState` method. The runtime members exist;
|
|
21
|
+
// TS hides them. Cast through `unknown` to avoid `any`.
|
|
22
|
+
type ComponentAnalysisLike = unknown;
|
|
23
|
+
interface TestScannerView {
|
|
24
|
+
project: import('ts-morph').Project;
|
|
25
|
+
analyzeState: (sourceFile: import('ts-morph').SourceFile, filePath: string) => ComponentAnalysisLike;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeScanner(): TestScannerView {
|
|
29
|
+
// ScannerConfig requires componentPaths/filePattern/exclude. Pass empty values
|
|
30
|
+
// — the regression tests stuff source files into `project` directly via
|
|
31
|
+
// createSourceFile, so the scanner never reads the filesystem.
|
|
32
|
+
const scanner = new ComponentScanner({
|
|
33
|
+
componentPaths: [],
|
|
34
|
+
filePattern: '*.tsx',
|
|
35
|
+
exclude: [],
|
|
36
|
+
});
|
|
37
|
+
return scanner as unknown as TestScannerView;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function fixturePath(relative: string): string {
|
|
41
|
+
return path.resolve(process.cwd(), 'tools/figma-plugin/scanner/__fixtures__', relative);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const scanner = makeScanner();
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Case 1: <Hero> wrapper component as root — direct uppercase wrapper.
|
|
48
|
+
// Was already covered by the original heuristic; included so a future
|
|
49
|
+
// "simplification" doesn't regress this.
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
{
|
|
52
|
+
// File basename must match the exported function name — the scanner
|
|
53
|
+
// uses the basename to find the component definition.
|
|
54
|
+
const file = scanner.project.createSourceFile(
|
|
55
|
+
fixturePath('hero-section.tsx'),
|
|
56
|
+
`
|
|
57
|
+
import { Hero } from '@/components/hero';
|
|
58
|
+
|
|
59
|
+
export function HeroSection() {
|
|
60
|
+
return (
|
|
61
|
+
<Hero>
|
|
62
|
+
<a className="hover:ring-gray-900/20 ring-1">Get started</a>
|
|
63
|
+
</Hero>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
`,
|
|
67
|
+
{ overwrite: true }
|
|
68
|
+
);
|
|
69
|
+
const result = scanner.analyzeState(file, file.getFilePath());
|
|
70
|
+
assert.equal(
|
|
71
|
+
result,
|
|
72
|
+
null,
|
|
73
|
+
'A component whose root JSX is an uppercase wrapper (<Hero>) must NOT be classified as `state` even when a nested element has hover classes.'
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Case 2: fragment-rooted component — the actual recurring break.
|
|
79
|
+
// HeroSection's root expands to `<><div className="relative isolate">...
|
|
80
|
+
// </div></>` once Hero is inlined. Pre-fix, the empty-tagName fragment
|
|
81
|
+
// failed the uppercase wrapper check and got treated as a state primitive.
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
{
|
|
84
|
+
// Filename matches function name so the scanner can resolve the JSX tree.
|
|
85
|
+
const file = scanner.project.createSourceFile(
|
|
86
|
+
fixturePath('fragment-rooted-section.tsx'),
|
|
87
|
+
`
|
|
88
|
+
export function FragmentRootedSection() {
|
|
89
|
+
return (
|
|
90
|
+
<>
|
|
91
|
+
<div className="relative isolate">
|
|
92
|
+
<a className="hover:ring-gray-900/20 ring-1">Get started</a>
|
|
93
|
+
</div>
|
|
94
|
+
</>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
`,
|
|
98
|
+
{ overwrite: true }
|
|
99
|
+
);
|
|
100
|
+
const result = scanner.analyzeState(file, file.getFilePath());
|
|
101
|
+
assert.equal(
|
|
102
|
+
result,
|
|
103
|
+
null,
|
|
104
|
+
'A component whose root JSX is a fragment (<>...</>) must NOT be classified as `state` — only deeply nested elements carry the hover/focus classes.'
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Case 3: the positive control. A real state primitive — root is a plain
|
|
110
|
+
// HTML element with hover/focus classes directly on it. Must STILL be
|
|
111
|
+
// classified as state. Ensures we haven't accidentally killed the feature.
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
{
|
|
114
|
+
// Filename matches function name so the scanner resolves the JSX tree.
|
|
115
|
+
const file = scanner.project.createSourceFile(
|
|
116
|
+
fixturePath('styled-input.tsx'),
|
|
117
|
+
`
|
|
118
|
+
export function StyledInput(props: any) {
|
|
119
|
+
return (
|
|
120
|
+
<input
|
|
121
|
+
{...props}
|
|
122
|
+
className="border h-9 px-3 hover:border-primary focus-visible:ring-2 disabled:opacity-50"
|
|
123
|
+
/>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
`,
|
|
127
|
+
{ overwrite: true }
|
|
128
|
+
);
|
|
129
|
+
const result = scanner.analyzeState(file, file.getFilePath());
|
|
130
|
+
assert.notEqual(
|
|
131
|
+
result,
|
|
132
|
+
null,
|
|
133
|
+
'A genuine state primitive (root <input> with hover/focus/disabled classes directly on it) must STILL be classified as `state`.'
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Case 4: shadcn-style Label — only carries `peer-disabled:` and
|
|
139
|
+
// `group-data-[disabled=true]:` modifiers (passive reactions to a
|
|
140
|
+
// parent's state, NOT its own state axis). Must NOT be classified as
|
|
141
|
+
// `state` — otherwise the runtime renders it via the state-master
|
|
142
|
+
// path, which has no slot for the label's text children, and a story
|
|
143
|
+
// like `<Dialog>... <Label>Name</Label> <Input/> ...</Dialog>` ends up
|
|
144
|
+
// with the labels invisible. Lock this in so a future widening of the
|
|
145
|
+
// state-modifier list doesn't silently re-break it.
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
{
|
|
148
|
+
const file = scanner.project.createSourceFile(
|
|
149
|
+
fixturePath('passive-label.tsx'),
|
|
150
|
+
`
|
|
151
|
+
export function Label({ className, ...props }: any) {
|
|
152
|
+
return (
|
|
153
|
+
<label
|
|
154
|
+
className={
|
|
155
|
+
"flex items-center gap-2 text-sm leading-none font-medium select-none " +
|
|
156
|
+
"group-data-[disabled=true]:pointer-events-none " +
|
|
157
|
+
"group-data-[disabled=true]:opacity-50 " +
|
|
158
|
+
"peer-disabled:cursor-not-allowed peer-disabled:opacity-50"
|
|
159
|
+
}
|
|
160
|
+
{...props}
|
|
161
|
+
/>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
`,
|
|
165
|
+
{ overwrite: true }
|
|
166
|
+
);
|
|
167
|
+
const result = scanner.analyzeState(file, file.getFilePath());
|
|
168
|
+
assert.equal(
|
|
169
|
+
result,
|
|
170
|
+
null,
|
|
171
|
+
'A component whose only "state" modifiers are passive `group-*:` / `peer-*:` reactions must NOT be classified as `state` — it has no own-element state axis, and the runtime would lose its text children when rendering via the state-master path.',
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
console.log('state-classification-regression: ok');
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { _internal, setStoryRenderDiagnostics } from '../src/design-system/story-diagnostics';
|
|
3
|
+
import type { ComponentDef, ComponentStory, LayoutInfo } from '../src/components';
|
|
4
|
+
import type { JsxNode } from '../src/tailwind';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Regression: story-diagnostics writes the `inkbridge:story-scan` and
|
|
8
|
+
* `inkbridge:story-render` plugin-data values that the `Debug Selection`
|
|
9
|
+
* plugin command reads. The string format is contract — anyone reading the
|
|
10
|
+
* pluginData (the dev console output, future tooling) parses by the
|
|
11
|
+
* `key=value;key=value` shape. Lock the shape and the per-helper inputs.
|
|
12
|
+
*
|
|
13
|
+
* Extracted from `src/design-system/story-builder.ts` into
|
|
14
|
+
* `src/design-system/story-diagnostics.ts`. setStoryRenderDiagnostics is a
|
|
15
|
+
* side-effect on a LayoutInfo (Figma node); the regression uses a stub that
|
|
16
|
+
* captures setPluginData calls so the format can be asserted in Node.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { getStoryTreeRootTag, getStoryTreeRootChildCount, summarizeLayoutClasses } = _internal;
|
|
20
|
+
|
|
21
|
+
function el(tag: string, children: JsxNode[] = []): JsxNode {
|
|
22
|
+
return { type: 'element', tagName: tag, props: {}, children } as unknown as JsxNode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function story(overrides: Partial<ComponentStory> = {}): ComponentStory {
|
|
26
|
+
return { name: 'TestStory', jsxTree: null, instances: [], layoutClasses: [], ...overrides } as ComponentStory;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// getStoryTreeRootTag — null/non-element/missing tag tolerances
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
assert.equal(getStoryTreeRootTag(story({ jsxTree: null })), '', 'null jsxTree → empty string');
|
|
34
|
+
assert.equal(getStoryTreeRootTag(story({ jsxTree: el('div') })), 'div', 'div root → "div"');
|
|
35
|
+
assert.equal(
|
|
36
|
+
getStoryTreeRootTag(story({ jsxTree: el('Header') })),
|
|
37
|
+
'Header',
|
|
38
|
+
'component-cased tag preserved',
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// Non-element root (text-only).
|
|
42
|
+
{
|
|
43
|
+
const tree = { type: 'text', value: 'hello' } as unknown as JsxNode;
|
|
44
|
+
assert.equal(
|
|
45
|
+
getStoryTreeRootTag(story({ jsxTree: tree })),
|
|
46
|
+
'',
|
|
47
|
+
'text-root story has no tag — return empty string',
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// getStoryTreeRootChildCount — null / array / non-array tolerances
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
assert.equal(getStoryTreeRootChildCount(story({ jsxTree: null })), 0, 'null tree → 0');
|
|
56
|
+
assert.equal(getStoryTreeRootChildCount(story({ jsxTree: el('div') })), 0, 'empty children → 0');
|
|
57
|
+
assert.equal(
|
|
58
|
+
getStoryTreeRootChildCount(story({ jsxTree: el('div', [el('span'), el('span'), el('span')]) })),
|
|
59
|
+
3,
|
|
60
|
+
'three children → 3',
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// Non-array children defensive: when jsxTree has children: undefined or null.
|
|
64
|
+
{
|
|
65
|
+
const tree = { type: 'element', tagName: 'div', props: {}, children: null } as unknown as JsxNode;
|
|
66
|
+
assert.equal(
|
|
67
|
+
getStoryTreeRootChildCount(story({ jsxTree: tree })),
|
|
68
|
+
0,
|
|
69
|
+
'non-array children → 0 (not NaN, not throw)',
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// summarizeLayoutClasses — join + truncation
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
assert.equal(summarizeLayoutClasses(undefined, 50), '', 'undefined input → empty string');
|
|
78
|
+
assert.equal(summarizeLayoutClasses(['flex', 'p-4'], 50), 'flex p-4', 'array joins by space');
|
|
79
|
+
assert.equal(
|
|
80
|
+
summarizeLayoutClasses(['a', 'b', 'c'], 3),
|
|
81
|
+
'a b',
|
|
82
|
+
'truncates when joined length > maxLength',
|
|
83
|
+
);
|
|
84
|
+
assert.equal(
|
|
85
|
+
summarizeLayoutClasses(['short'], 10),
|
|
86
|
+
'short',
|
|
87
|
+
'no truncation when joined length <= maxLength',
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// Edge: empty array → empty string.
|
|
91
|
+
assert.equal(summarizeLayoutClasses([], 50), '', 'empty array → empty string');
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// setStoryRenderDiagnostics — full integration: format + plugin-data writes
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
// Stub LayoutInfo that captures setPluginData calls.
|
|
98
|
+
function makeLayoutStub(): { layout: LayoutInfo; writes: Record<string, string> } {
|
|
99
|
+
const writes: Record<string, string> = {};
|
|
100
|
+
const layout = {
|
|
101
|
+
children: [],
|
|
102
|
+
setPluginData: (key: string, value: string) => {
|
|
103
|
+
writes[key] = value;
|
|
104
|
+
},
|
|
105
|
+
} as unknown as LayoutInfo;
|
|
106
|
+
return { layout, writes };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Happy path: writes all four plugin-data keys with the documented format.
|
|
110
|
+
{
|
|
111
|
+
const { layout, writes } = makeLayoutStub();
|
|
112
|
+
const def = { name: 'Button' } as ComponentDef;
|
|
113
|
+
const tree = el('div', [el('span'), el('p')]);
|
|
114
|
+
const s = story({
|
|
115
|
+
name: 'Default',
|
|
116
|
+
jsxTree: tree,
|
|
117
|
+
instances: [{ componentName: 'Button' } as unknown],
|
|
118
|
+
layoutClasses: ['flex', 'p-4'],
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
setStoryRenderDiagnostics(layout, def, s, 'tree', 5, true);
|
|
122
|
+
|
|
123
|
+
assert.equal(writes['inkbridge:story-name'], 'Default', 'story name written');
|
|
124
|
+
assert.equal(writes['inkbridge:story-def'], 'Button', 'def name written');
|
|
125
|
+
|
|
126
|
+
// scanSummary format: jsx=...;root=...;rootChildren=...;instances=...;layout=...
|
|
127
|
+
assert.equal(
|
|
128
|
+
writes['inkbridge:story-scan'],
|
|
129
|
+
'jsx=1;root=div;rootChildren=2;instances=1;layout=flex p-4',
|
|
130
|
+
'scan summary uses canonical key=value;... format',
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
// renderSummary format: mode=...;added=...;treeSelected=...;children=...;engine=...
|
|
134
|
+
assert.match(
|
|
135
|
+
writes['inkbridge:story-render'],
|
|
136
|
+
/^mode=tree;added=5;treeSelected=1;children=0;engine=.+$/,
|
|
137
|
+
'render summary uses canonical key=value;... format, engine version present',
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// jsx=0 path when no jsxTree
|
|
142
|
+
{
|
|
143
|
+
const { layout, writes } = makeLayoutStub();
|
|
144
|
+
setStoryRenderDiagnostics(
|
|
145
|
+
layout,
|
|
146
|
+
{ name: 'Button' } as ComponentDef,
|
|
147
|
+
story({ jsxTree: null }),
|
|
148
|
+
'instance',
|
|
149
|
+
1,
|
|
150
|
+
false,
|
|
151
|
+
);
|
|
152
|
+
assert.match(writes['inkbridge:story-scan'], /^jsx=0;root=;rootChildren=0;/, 'no tree → jsx=0;root=');
|
|
153
|
+
assert.match(writes['inkbridge:story-render'], /treeSelected=0/, 'treeSelected reflects useStoryTree=false');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Defensive: missing setPluginData → no throw
|
|
157
|
+
{
|
|
158
|
+
const layoutWithoutSetPluginData = { children: [] } as unknown as LayoutInfo;
|
|
159
|
+
// Should silently do nothing rather than throw.
|
|
160
|
+
setStoryRenderDiagnostics(
|
|
161
|
+
layoutWithoutSetPluginData,
|
|
162
|
+
{ name: 'X' } as ComponentDef,
|
|
163
|
+
story(),
|
|
164
|
+
'mode',
|
|
165
|
+
0,
|
|
166
|
+
false,
|
|
167
|
+
);
|
|
168
|
+
// (no assertion needed — getting here means no throw)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Defensive: setPluginData throws → caught silently
|
|
172
|
+
{
|
|
173
|
+
const writes: Record<string, string> = {};
|
|
174
|
+
let throwCount = 0;
|
|
175
|
+
const layout = {
|
|
176
|
+
children: [],
|
|
177
|
+
setPluginData: (key: string, value: string) => {
|
|
178
|
+
if (key === 'inkbridge:story-scan') {
|
|
179
|
+
throwCount++;
|
|
180
|
+
throw new Error('simulated figma failure');
|
|
181
|
+
}
|
|
182
|
+
writes[key] = value;
|
|
183
|
+
},
|
|
184
|
+
} as unknown as LayoutInfo;
|
|
185
|
+
|
|
186
|
+
setStoryRenderDiagnostics(
|
|
187
|
+
layout,
|
|
188
|
+
{ name: 'X' } as ComponentDef,
|
|
189
|
+
story(),
|
|
190
|
+
'mode',
|
|
191
|
+
0,
|
|
192
|
+
false,
|
|
193
|
+
);
|
|
194
|
+
// We don't care which writes succeeded — only that the throw didn't escape.
|
|
195
|
+
assert.equal(throwCount, 1, 'inner throw was reached');
|
|
196
|
+
// (no further assertion needed — getting here means the outer catch swallowed the error)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Layout-class truncation: very long class list is capped at 120 chars in
|
|
200
|
+
// the scan summary.
|
|
201
|
+
{
|
|
202
|
+
const { layout, writes } = makeLayoutStub();
|
|
203
|
+
const longClasses = Array.from({ length: 50 }, (_, i) => `cls-${i}`); // ~6*50 = 300+ chars joined
|
|
204
|
+
setStoryRenderDiagnostics(
|
|
205
|
+
layout,
|
|
206
|
+
{ name: 'X' } as ComponentDef,
|
|
207
|
+
story({ layoutClasses: longClasses }),
|
|
208
|
+
'mode',
|
|
209
|
+
0,
|
|
210
|
+
false,
|
|
211
|
+
);
|
|
212
|
+
const layoutSegment = writes['inkbridge:story-scan'].split('layout=')[1] || '';
|
|
213
|
+
assert.ok(layoutSegment.length <= 120, `layout summary capped at 120 chars (got ${layoutSegment.length})`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
console.log('story-diagnostics-regression: PASS');
|