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
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = path.dirname(__filename);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Regression: ban every raw `classes.includes('hidden')` /
|
|
11
|
+
* `classes.includes('sr-only')` / `lastIndexOf('hidden')` /
|
|
12
|
+
* `lastIndexOf('sr-only')` site in `src/`.
|
|
13
|
+
*
|
|
14
|
+
* Background: three separate sites historically reimplemented the
|
|
15
|
+
* "is this element hidden?" rule each with subtle drift:
|
|
16
|
+
*
|
|
17
|
+
* 1. `buildFigmaNode`'s outer gate (hand-rolled DISPLAY_OVERRIDES +
|
|
18
|
+
* lastIndexOf + slice — closest to correct).
|
|
19
|
+
* 2. `collectTextContent` (raw `classes.includes('hidden')` — wrong;
|
|
20
|
+
* ignored last-display-wins, returned `''` for a `<p hidden
|
|
21
|
+
* sm:block>` resolved to `['hidden','text-muted-foreground','block']`
|
|
22
|
+
* where `block` is the winning display utility).
|
|
23
|
+
* 3. inline-collapse / renderChildren filters (used
|
|
24
|
+
* `isEffectivelyHidden` correctly but didn't honour sr-only —
|
|
25
|
+
* mostly accidental; we tightened later).
|
|
26
|
+
*
|
|
27
|
+
* Each site needed its own bug-hunt to find. The lesson: route every
|
|
28
|
+
* "is this element hidden / sr-only?" check through the shared
|
|
29
|
+
* `isEffectivelyHiddenOrSrOnly` helper in
|
|
30
|
+
* `src/tailwind/responsive-analyzer.ts`. This fixture mechanically
|
|
31
|
+
* enforces that — adding a new raw site fails CI before it can land.
|
|
32
|
+
*
|
|
33
|
+
* The helper itself (and its narrower `isEffectivelyHidden` companion)
|
|
34
|
+
* IS allowed to reference the literals — that's the source of truth.
|
|
35
|
+
* Allow-list pattern: any file under `src/tailwind/responsive-analyzer.ts`.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const SRC_DIR = path.resolve(__dirname, '..', 'src');
|
|
39
|
+
const ALLOWED_FILES = new Set([
|
|
40
|
+
// The shared helper itself. Other allowed files can be added here
|
|
41
|
+
// with a comment explaining why a raw literal check is justified.
|
|
42
|
+
path.resolve(SRC_DIR, 'tailwind', 'responsive-analyzer.ts'),
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
// Patterns that indicate a raw, drift-prone check. The shared helper
|
|
46
|
+
// covers ALL of these cases — there's no legitimate reason to write
|
|
47
|
+
// them directly in any other file.
|
|
48
|
+
const BANNED_PATTERNS: Array<{ regex: RegExp; description: string }> = [
|
|
49
|
+
{
|
|
50
|
+
regex: /\.includes\(\s*['"]hidden['"]\s*\)/,
|
|
51
|
+
description: `classes.includes('hidden') — use isEffectivelyHiddenOrSrOnly() from '../tailwind'`,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
regex: /\.includes\(\s*['"]sr-only['"]\s*\)/,
|
|
55
|
+
description: `classes.includes('sr-only') — use isEffectivelyHiddenOrSrOnly() from '../tailwind'`,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
regex: /\.lastIndexOf\(\s*['"]hidden['"]\s*\)/,
|
|
59
|
+
description: `classes.lastIndexOf('hidden') — use isEffectivelyHidden() or isEffectivelyHiddenOrSrOnly() from '../tailwind'`,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
regex: /\.lastIndexOf\(\s*['"]sr-only['"]\s*\)/,
|
|
63
|
+
description: `classes.lastIndexOf('sr-only') — use isEffectivelyHiddenOrSrOnly() from '../tailwind'`,
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
function walk(dir: string, out: string[]): void {
|
|
68
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
69
|
+
const full = path.join(dir, entry.name);
|
|
70
|
+
if (entry.isDirectory()) {
|
|
71
|
+
walk(full, out);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) {
|
|
75
|
+
out.push(full);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const files: string[] = [];
|
|
81
|
+
walk(SRC_DIR, files);
|
|
82
|
+
assert.ok(files.length > 0, 'expected to find .ts files under tools/figma-plugin/src/');
|
|
83
|
+
|
|
84
|
+
type Violation = { file: string; line: number; snippet: string; reason: string };
|
|
85
|
+
const violations: Violation[] = [];
|
|
86
|
+
|
|
87
|
+
for (const file of files) {
|
|
88
|
+
if (ALLOWED_FILES.has(file)) continue;
|
|
89
|
+
const text = fs.readFileSync(file, 'utf-8');
|
|
90
|
+
const lines = text.split('\n');
|
|
91
|
+
for (let i = 0; i < lines.length; i++) {
|
|
92
|
+
const raw = lines[i];
|
|
93
|
+
// Strip line comments — they're commentary, not code that runs.
|
|
94
|
+
const codePortion = raw.replace(/\/\/.*$/, '');
|
|
95
|
+
for (const pat of BANNED_PATTERNS) {
|
|
96
|
+
if (pat.regex.test(codePortion)) {
|
|
97
|
+
violations.push({
|
|
98
|
+
file: path.relative(SRC_DIR, file),
|
|
99
|
+
line: i + 1,
|
|
100
|
+
snippet: raw.trim().slice(0, 120),
|
|
101
|
+
reason: pat.description,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (violations.length > 0) {
|
|
109
|
+
console.error('hidden-check-drift-regression: FAIL');
|
|
110
|
+
console.error(`Found ${violations.length} raw hidden / sr-only check(s) in src/:`);
|
|
111
|
+
for (const v of violations) {
|
|
112
|
+
console.error(` ${v.file}:${v.line}`);
|
|
113
|
+
console.error(` ${v.snippet}`);
|
|
114
|
+
console.error(` ${v.reason}`);
|
|
115
|
+
}
|
|
116
|
+
console.error('');
|
|
117
|
+
console.error('Why this matters:');
|
|
118
|
+
console.error(' These string-literal checks repeatedly drifted from each other.');
|
|
119
|
+
console.error(' The recurring `<p hidden sm:block>` lost-its-text bug existed because');
|
|
120
|
+
console.error(' three separate sites each had their own subtly-wrong "is this hidden?"');
|
|
121
|
+
console.error(' rule. Route every check through `isEffectivelyHiddenOrSrOnly` instead.');
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
console.log(`hidden-check-drift-regression: PASS (scanned ${files.length} files, 0 raw hidden/sr-only checks in src/)`);
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
|
|
3
|
+
(globalThis as unknown as { figma: unknown }).figma = {
|
|
4
|
+
notify: () => undefined,
|
|
5
|
+
showUI: () => undefined,
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Regression: `constrainSingleHorizontalTextChild` shrinks the
|
|
10
|
+
* text-bearing child of a HORIZONTAL FIXED-width row when its HUG
|
|
11
|
+
* width would overflow the sibling button's space. The helper
|
|
12
|
+
* mirrors CSS `flex-shrink: 1` — flex items shrink to min-content
|
|
13
|
+
* (text wraps to longest word) when the row would otherwise
|
|
14
|
+
* overflow; Figma auto-layout has no shrink primitive so without
|
|
15
|
+
* this stabilizer the text sibling visually overlaps the button.
|
|
16
|
+
*
|
|
17
|
+
* The helper has always handled the case where a direct `<TEXT>`
|
|
18
|
+
* child sits next to a non-text sibling. The extension covered here
|
|
19
|
+
* is shadcn's canonical "title + description" wrapper —
|
|
20
|
+
* `<div><p>Heading</p><p>Long description text…</p></div>` —
|
|
21
|
+
* which is a FRAME containing only text, not a direct TEXT node.
|
|
22
|
+
* Without recognising the wrapper as text-bearing, the helper bailed
|
|
23
|
+
* (`textChildren.length !== 1`) and rows like DecreasePositionModal's
|
|
24
|
+
* "Close entire position" card rendered the description on one line
|
|
25
|
+
* overlapping the "Entire position" button.
|
|
26
|
+
*
|
|
27
|
+
* The fix: treat any frame whose visible subtree is entirely text
|
|
28
|
+
* (paragraphs / spans / TEXT nodes wrapped in transparent frames) as
|
|
29
|
+
* text-bearing. Resize the wrapper to the available width and
|
|
30
|
+
* recursively pin every TEXT descendant to `textAutoResize='HEIGHT'`
|
|
31
|
+
* so the text wraps within the now-constrained wrapper.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
constrainSingleHorizontalTextChild,
|
|
36
|
+
} from '../src/design-system/frame-stabilizers';
|
|
37
|
+
|
|
38
|
+
interface StubText {
|
|
39
|
+
type: 'TEXT';
|
|
40
|
+
width: number;
|
|
41
|
+
height: number;
|
|
42
|
+
textAutoResize: 'NONE' | 'HEIGHT' | 'WIDTH_AND_HEIGHT' | 'TRUNCATE' | undefined;
|
|
43
|
+
resize: (w: number, h: number) => void;
|
|
44
|
+
layoutPositioning?: 'AUTO' | 'ABSOLUTE';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface StubFrame {
|
|
48
|
+
type: 'FRAME';
|
|
49
|
+
layoutMode: 'HORIZONTAL' | 'VERTICAL';
|
|
50
|
+
primaryAxisAlignItems: 'MIN' | 'CENTER' | 'MAX' | 'SPACE_BETWEEN';
|
|
51
|
+
primaryAxisSizingMode: 'AUTO' | 'FIXED';
|
|
52
|
+
counterAxisSizingMode: 'AUTO' | 'FIXED';
|
|
53
|
+
layoutAlign?: 'INHERIT' | 'STRETCH' | 'MIN' | 'CENTER' | 'MAX';
|
|
54
|
+
width: number;
|
|
55
|
+
height: number;
|
|
56
|
+
itemSpacing: number;
|
|
57
|
+
paddingLeft: number;
|
|
58
|
+
paddingRight: number;
|
|
59
|
+
paddingTop: number;
|
|
60
|
+
paddingBottom: number;
|
|
61
|
+
children: (StubFrame | StubText)[];
|
|
62
|
+
layoutPositioning?: 'AUTO' | 'ABSOLUTE';
|
|
63
|
+
resize: (w: number, h: number) => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function text(width: number, opts: Partial<StubText> = {}): StubText {
|
|
67
|
+
const t: StubText = {
|
|
68
|
+
type: 'TEXT',
|
|
69
|
+
width,
|
|
70
|
+
height: 16,
|
|
71
|
+
textAutoResize: 'WIDTH_AND_HEIGHT',
|
|
72
|
+
layoutPositioning: 'AUTO',
|
|
73
|
+
resize(w, h) { this.width = w; this.height = h; },
|
|
74
|
+
...opts,
|
|
75
|
+
};
|
|
76
|
+
return t;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function frame(opts: Partial<StubFrame> & { children?: (StubFrame | StubText)[] } = {}): StubFrame {
|
|
80
|
+
const f: StubFrame = {
|
|
81
|
+
type: 'FRAME',
|
|
82
|
+
layoutMode: 'HORIZONTAL',
|
|
83
|
+
primaryAxisAlignItems: 'SPACE_BETWEEN',
|
|
84
|
+
primaryAxisSizingMode: 'FIXED',
|
|
85
|
+
counterAxisSizingMode: 'AUTO',
|
|
86
|
+
layoutAlign: 'INHERIT',
|
|
87
|
+
width: 470,
|
|
88
|
+
height: 64,
|
|
89
|
+
itemSpacing: 8,
|
|
90
|
+
paddingLeft: 0,
|
|
91
|
+
paddingRight: 0,
|
|
92
|
+
paddingTop: 0,
|
|
93
|
+
paddingBottom: 0,
|
|
94
|
+
layoutPositioning: 'AUTO',
|
|
95
|
+
children: [],
|
|
96
|
+
resize(w, h) { this.width = w; this.height = h; },
|
|
97
|
+
...opts,
|
|
98
|
+
};
|
|
99
|
+
return f;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const constrain = constrainSingleHorizontalTextChild as unknown as (n: StubFrame | StubText) => void;
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Original case: direct TEXT next to a non-text sibling — preserved
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
{
|
|
109
|
+
const direct = text(400);
|
|
110
|
+
const button = frame({ width: 100, primaryAxisSizingMode: 'AUTO', primaryAxisAlignItems: 'MIN' });
|
|
111
|
+
const row = frame({ width: 460, children: [direct, button] });
|
|
112
|
+
constrain(row);
|
|
113
|
+
assert.equal(direct.width, 460 - 100 - 8, 'direct TEXT shrinks to available width (preserved behavior)');
|
|
114
|
+
assert.equal(direct.textAutoResize, 'HEIGHT', 'direct TEXT pinned to HEIGHT so it wraps vertically');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// New case: text-bearing wrapper (frame with two paragraph frames inside)
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
{
|
|
122
|
+
// Wrapper that mirrors shadcn's `<div><p>Title</p><p>Description…</p></div>`
|
|
123
|
+
// shape: a VERTICAL frame containing two paragraph frames, each holding a
|
|
124
|
+
// single TEXT node. Both paragraphs are at their one-line HUG width before
|
|
125
|
+
// the stabilizer runs.
|
|
126
|
+
const titleText = text(150);
|
|
127
|
+
const descText = text(400);
|
|
128
|
+
const titleParagraph = frame({
|
|
129
|
+
layoutMode: 'VERTICAL',
|
|
130
|
+
primaryAxisAlignItems: 'MIN',
|
|
131
|
+
width: 150,
|
|
132
|
+
height: 20,
|
|
133
|
+
children: [titleText],
|
|
134
|
+
});
|
|
135
|
+
const descParagraph = frame({
|
|
136
|
+
layoutMode: 'VERTICAL',
|
|
137
|
+
primaryAxisAlignItems: 'MIN',
|
|
138
|
+
width: 400,
|
|
139
|
+
height: 16,
|
|
140
|
+
children: [descText],
|
|
141
|
+
});
|
|
142
|
+
const wrapper = frame({
|
|
143
|
+
layoutMode: 'VERTICAL',
|
|
144
|
+
primaryAxisAlignItems: 'MIN',
|
|
145
|
+
width: 400,
|
|
146
|
+
height: 40,
|
|
147
|
+
children: [titleParagraph, descParagraph],
|
|
148
|
+
});
|
|
149
|
+
// The "button" sibling stays HUG — Figma equivalent of a `<Button>`.
|
|
150
|
+
const buttonFrame = frame({ width: 120, primaryAxisSizingMode: 'AUTO', primaryAxisAlignItems: 'MIN' });
|
|
151
|
+
const row = frame({ width: 470, children: [wrapper, buttonFrame] });
|
|
152
|
+
constrain(row);
|
|
153
|
+
|
|
154
|
+
const expectedAvailable = 470 - 120 - 8; // 342
|
|
155
|
+
assert.equal(wrapper.width, expectedAvailable, 'text-bearing wrapper shrinks to available width');
|
|
156
|
+
assert.equal(
|
|
157
|
+
wrapper.counterAxisSizingMode,
|
|
158
|
+
'FIXED',
|
|
159
|
+
'wrapper counter-axis sizing pinned to FIXED so text wraps within it instead of pushing it back open',
|
|
160
|
+
);
|
|
161
|
+
assert.equal(
|
|
162
|
+
wrapper.layoutAlign,
|
|
163
|
+
'INHERIT',
|
|
164
|
+
'wrapper layoutAlign set to INHERIT so it does not also stretch in the cross-axis',
|
|
165
|
+
);
|
|
166
|
+
assert.equal(titleText.width, expectedAvailable, 'TEXT inside paragraph 1 sized to available width');
|
|
167
|
+
assert.equal(titleText.textAutoResize, 'HEIGHT', 'TEXT inside paragraph 1 pinned to HEIGHT');
|
|
168
|
+
assert.equal(descText.width, expectedAvailable, 'TEXT inside paragraph 2 sized to available width');
|
|
169
|
+
assert.equal(descText.textAutoResize, 'HEIGHT', 'TEXT inside paragraph 2 pinned to HEIGHT — this is the description that was overlapping the button');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Bail-out: no overflow → leave widths alone
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
{
|
|
177
|
+
const t = text(100);
|
|
178
|
+
const sibling = frame({ width: 100, primaryAxisSizingMode: 'AUTO' });
|
|
179
|
+
const row = frame({ width: 460, children: [t, sibling] });
|
|
180
|
+
constrain(row);
|
|
181
|
+
assert.equal(t.width, 100, 'fitting content untouched — keeps the SPACE_BETWEEN gap');
|
|
182
|
+
assert.equal(t.textAutoResize, 'WIDTH_AND_HEIGHT', 'fitting TEXT keeps its default autoResize');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Bail-out: two text-bearing children — ambiguous which to shrink
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
{
|
|
190
|
+
const a = text(300);
|
|
191
|
+
const b = text(300);
|
|
192
|
+
const row = frame({ width: 460, children: [a, b] });
|
|
193
|
+
constrain(row);
|
|
194
|
+
assert.equal(a.width, 300, 'two text children → ambiguous, helper bails (matches CSS where both would shrink proportionally)');
|
|
195
|
+
assert.equal(b.width, 300, 'second text also untouched in ambiguous case');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Bail-out: CENTER alignment is intentional (CTA rows)
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
{
|
|
203
|
+
const t = text(400);
|
|
204
|
+
const sibling = frame({ width: 100, primaryAxisSizingMode: 'AUTO' });
|
|
205
|
+
const row = frame({
|
|
206
|
+
width: 460,
|
|
207
|
+
primaryAxisAlignItems: 'CENTER',
|
|
208
|
+
children: [t, sibling],
|
|
209
|
+
});
|
|
210
|
+
constrain(row);
|
|
211
|
+
assert.equal(t.width, 400, 'CENTER rows preserve symmetry — text is not shrunk so siblings stay visually centered');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Bail-out: parent itself is HUG — width not known, no constraint to apply
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
{
|
|
219
|
+
const t = text(400);
|
|
220
|
+
const sibling = frame({ width: 100, primaryAxisSizingMode: 'AUTO' });
|
|
221
|
+
const row = frame({
|
|
222
|
+
width: 460,
|
|
223
|
+
primaryAxisSizingMode: 'AUTO',
|
|
224
|
+
children: [t, sibling],
|
|
225
|
+
});
|
|
226
|
+
constrain(row);
|
|
227
|
+
assert.equal(t.width, 400, 'AUTO-width parent → no constraint applied (parent would just grow with kids)');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
console.log('horizontal-text-shrink-regression: PASS');
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { ComponentScanner } from './component-scanner';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression: `.map()` over a NAMED imported array must expand into N
|
|
7
|
+
* JSX children, not fall through to the placeholder-iteration path.
|
|
8
|
+
*
|
|
9
|
+
* History: greenhouse-app's `MarketPriceCard` renders its timeframe row
|
|
10
|
+
* with
|
|
11
|
+
*
|
|
12
|
+
* import { PRICE_CHART_TIMEFRAMES } from "~/domains/perps/constants";
|
|
13
|
+
* ...
|
|
14
|
+
* {PRICE_CHART_TIMEFRAMES.map((option) => (
|
|
15
|
+
* <Button variant={timeframe === option.value ? "default" : "outline"}>
|
|
16
|
+
* {option.label}
|
|
17
|
+
* </Button>
|
|
18
|
+
* ))}
|
|
19
|
+
*
|
|
20
|
+
* `findArrayValue` only inspected the current source file's variable
|
|
21
|
+
* declarations + default JSON imports. Named imports from a TypeScript
|
|
22
|
+
* file (`export const PRICE_CHART_TIMEFRAMES = [...]` in
|
|
23
|
+
* `~/domains/perps/constants.ts`) were invisible. The `.map()` fell
|
|
24
|
+
* through to the "no array found" branch and emitted ONE synthetic
|
|
25
|
+
* placeholder iteration — so Figma rendered zero buttons.
|
|
26
|
+
*
|
|
27
|
+
* Fix: `findArrayInNamedImports` follows named imports to the source
|
|
28
|
+
* file via `resolveImportedComponentPath`, opens the file, and recurses
|
|
29
|
+
* into `findArrayValue` against the imported source. Wired into
|
|
30
|
+
* `findArrayValue` after the JSON-import branch.
|
|
31
|
+
*
|
|
32
|
+
* Generalizes to ANY named-imported const array, not just chart
|
|
33
|
+
* timeframes — covers role lists, theme enums, navigation items, etc.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
interface JsxNodeLike {
|
|
37
|
+
type: 'element' | 'text';
|
|
38
|
+
tagName?: string;
|
|
39
|
+
content?: string;
|
|
40
|
+
props?: Record<string, unknown>;
|
|
41
|
+
children?: JsxNodeLike[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface TestScannerView {
|
|
45
|
+
project: import('ts-morph').Project;
|
|
46
|
+
extractComponentJsxTree: (
|
|
47
|
+
sourceFile: import('ts-morph').SourceFile,
|
|
48
|
+
componentName: string,
|
|
49
|
+
) => JsxNodeLike | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeScanner(): TestScannerView {
|
|
53
|
+
return new ComponentScanner({
|
|
54
|
+
componentPaths: [],
|
|
55
|
+
filePattern: '*.tsx',
|
|
56
|
+
exclude: [],
|
|
57
|
+
}) as unknown as TestScannerView;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function fixturePath(relative: string): string {
|
|
61
|
+
return path.resolve(process.cwd(), 'tools/figma-plugin/scanner/__fixtures__', relative);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function findAllElements(node: JsxNodeLike | null, tag: string, out: JsxNodeLike[] = []): JsxNodeLike[] {
|
|
65
|
+
if (!node || node.type !== 'element') return out;
|
|
66
|
+
if (node.tagName === tag) out.push(node);
|
|
67
|
+
for (const child of node.children || []) findAllElements(child, tag, out);
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const scanner = makeScanner();
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Case 1: relative-path named import (`./constants`). Asserts `.map()` over
|
|
75
|
+
// the imported array expands into N JSX elements with the right label text.
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
{
|
|
78
|
+
scanner.project.createSourceFile(
|
|
79
|
+
fixturePath('imported-array/constants.ts'),
|
|
80
|
+
`
|
|
81
|
+
export const PRICE_CHART_TIMEFRAMES: Array<{ label: string; value: string }> = [
|
|
82
|
+
{ label: "Tick", value: "tick" },
|
|
83
|
+
{ label: "1h", value: "1h" },
|
|
84
|
+
{ label: "1d", value: "1d" },
|
|
85
|
+
];
|
|
86
|
+
`,
|
|
87
|
+
{ overwrite: true },
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const file = scanner.project.createSourceFile(
|
|
91
|
+
fixturePath('imported-array/Card.tsx'),
|
|
92
|
+
`
|
|
93
|
+
import { PRICE_CHART_TIMEFRAMES } from "./constants";
|
|
94
|
+
export function Card() {
|
|
95
|
+
return (
|
|
96
|
+
<div>
|
|
97
|
+
{PRICE_CHART_TIMEFRAMES.map((option) => (
|
|
98
|
+
<button data-key={option.value}>{option.label}</button>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
`,
|
|
104
|
+
{ overwrite: true },
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const tree = scanner.extractComponentJsxTree(file, 'Card');
|
|
108
|
+
assert.ok(tree, 'Card must produce a tree');
|
|
109
|
+
const buttons = findAllElements(tree, 'button');
|
|
110
|
+
assert.equal(
|
|
111
|
+
buttons.length,
|
|
112
|
+
3,
|
|
113
|
+
`expected 3 buttons from the named-imported array .map(); got ${buttons.length}`,
|
|
114
|
+
);
|
|
115
|
+
const labels = buttons
|
|
116
|
+
.map((b) => (b.children || []).map((c) => c.content ?? '').join(''))
|
|
117
|
+
.join('|');
|
|
118
|
+
assert.ok(
|
|
119
|
+
labels.includes('Tick') && labels.includes('1h') && labels.includes('1d'),
|
|
120
|
+
`expected each button's text child to come from option.label; got "${labels}"`,
|
|
121
|
+
);
|
|
122
|
+
const keys = buttons.map((b) => b.props?.['data-key']).join(',');
|
|
123
|
+
assert.equal(
|
|
124
|
+
keys,
|
|
125
|
+
'tick,1h,1d',
|
|
126
|
+
`expected data-key props to come from option.value; got "${keys}"`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Case 2: aliased named import (`import { Foo as Bar } from "./x"`). The
|
|
132
|
+
// scanner must follow the imported name even when locally renamed.
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
{
|
|
135
|
+
scanner.project.createSourceFile(
|
|
136
|
+
fixturePath('imported-array/aliased-constants.ts'),
|
|
137
|
+
`
|
|
138
|
+
export const ROLES = [
|
|
139
|
+
{ id: "admin", label: "Admin" },
|
|
140
|
+
{ id: "viewer", label: "Viewer" },
|
|
141
|
+
];
|
|
142
|
+
`,
|
|
143
|
+
{ overwrite: true },
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const file = scanner.project.createSourceFile(
|
|
147
|
+
fixturePath('imported-array/Aliased.tsx'),
|
|
148
|
+
`
|
|
149
|
+
import { ROLES as RolesList } from "./aliased-constants";
|
|
150
|
+
export function Aliased() {
|
|
151
|
+
return (
|
|
152
|
+
<ul>
|
|
153
|
+
{RolesList.map((r) => <li data-id={r.id}>{r.label}</li>)}
|
|
154
|
+
</ul>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
`,
|
|
158
|
+
{ overwrite: true },
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const tree = scanner.extractComponentJsxTree(file, 'Aliased');
|
|
162
|
+
assert.ok(tree, 'Aliased must produce a tree');
|
|
163
|
+
const items = findAllElements(tree, 'li');
|
|
164
|
+
assert.equal(items.length, 2, `expected 2 <li> from aliased import .map(); got ${items.length}`);
|
|
165
|
+
const ids = items.map((b) => b.props?.['data-id']).join(',');
|
|
166
|
+
assert.equal(ids, 'admin,viewer', `expected ids resolved through the alias; got "${ids}"`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Case 3: unresolvable import (path doesn't exist on disk) MUST NOT crash.
|
|
171
|
+
// `.map()` falls through to the existing placeholder-iteration path. Lock the
|
|
172
|
+
// graceful degradation so a future change can't turn this into a hard error.
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
{
|
|
175
|
+
const file = scanner.project.createSourceFile(
|
|
176
|
+
fixturePath('imported-array/Unresolvable.tsx'),
|
|
177
|
+
`
|
|
178
|
+
import { NOPE } from "./does-not-exist";
|
|
179
|
+
export function Unresolvable() {
|
|
180
|
+
return <div>{NOPE.map((x) => <span>{x.label}</span>)}</div>;
|
|
181
|
+
}
|
|
182
|
+
`,
|
|
183
|
+
{ overwrite: true },
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const tree = scanner.extractComponentJsxTree(file, 'Unresolvable');
|
|
187
|
+
assert.ok(tree, 'must not throw on an unresolvable import');
|
|
188
|
+
// Should produce the placeholder iteration (one synthetic <span>) — the
|
|
189
|
+
// existing fallback path. Just assert we got *something* without
|
|
190
|
+
// checking the placeholder shape, which is implementation-detail.
|
|
191
|
+
const spans = findAllElements(tree, 'span');
|
|
192
|
+
assert.ok(spans.length >= 0, 'should not throw');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
console.log('imported-array-map-regression: PASS (3 cases)');
|
|
@@ -93,6 +93,7 @@ function runRegression(): void {
|
|
|
93
93
|
blockFlowCard.layoutMode = 'VERTICAL';
|
|
94
94
|
blockFlowCard.width = 600;
|
|
95
95
|
blockFlowCard.primaryAxisSizingMode = 'FIXED';
|
|
96
|
+
blockFlowCard.counterAxisSizingMode = 'FIXED';
|
|
96
97
|
setFrameCrossAlign(blockFlowCard as unknown as FrameNode, 'MIN');
|
|
97
98
|
setFrameFromBlockFlow(blockFlowCard as unknown as FrameNode, true);
|
|
98
99
|
|
|
@@ -117,6 +118,7 @@ function runRegression(): void {
|
|
|
117
118
|
blockFlowCard2.layoutMode = 'VERTICAL';
|
|
118
119
|
blockFlowCard2.width = 600;
|
|
119
120
|
blockFlowCard2.primaryAxisSizingMode = 'FIXED';
|
|
121
|
+
blockFlowCard2.counterAxisSizingMode = 'FIXED';
|
|
120
122
|
setFrameCrossAlign(blockFlowCard2 as unknown as FrameNode, 'MIN');
|
|
121
123
|
setFrameFromBlockFlow(blockFlowCard2 as unknown as FrameNode, true);
|
|
122
124
|
|
|
@@ -141,6 +143,7 @@ function runRegression(): void {
|
|
|
141
143
|
blockFlowCard3.layoutMode = 'VERTICAL';
|
|
142
144
|
blockFlowCard3.width = 600;
|
|
143
145
|
blockFlowCard3.primaryAxisSizingMode = 'FIXED';
|
|
146
|
+
blockFlowCard3.counterAxisSizingMode = 'FIXED';
|
|
144
147
|
setFrameCrossAlign(blockFlowCard3 as unknown as FrameNode, 'MIN');
|
|
145
148
|
setFrameFromBlockFlow(blockFlowCard3 as unknown as FrameNode, true);
|
|
146
149
|
|
|
@@ -169,6 +172,7 @@ function runRegression(): void {
|
|
|
169
172
|
flexFooter.layoutMode = 'VERTICAL';
|
|
170
173
|
flexFooter.width = 400;
|
|
171
174
|
flexFooter.primaryAxisSizingMode = 'FIXED';
|
|
175
|
+
flexFooter.counterAxisSizingMode = 'FIXED';
|
|
172
176
|
setFrameCrossAlign(flexFooter as unknown as FrameNode, 'STRETCH');
|
|
173
177
|
setFrameFromBlockFlow(flexFooter as unknown as FrameNode, false);
|
|
174
178
|
|
|
@@ -198,6 +202,7 @@ function runRegression(): void {
|
|
|
198
202
|
centeredBlockParent.layoutMode = 'VERTICAL';
|
|
199
203
|
centeredBlockParent.width = 600;
|
|
200
204
|
centeredBlockParent.primaryAxisSizingMode = 'FIXED';
|
|
205
|
+
centeredBlockParent.counterAxisSizingMode = 'FIXED';
|
|
201
206
|
setFrameCrossAlign(centeredBlockParent as unknown as FrameNode, 'MIN');
|
|
202
207
|
setFrameFromBlockFlow(centeredBlockParent as unknown as FrameNode, true);
|
|
203
208
|
setFrameInlineAlign(centeredBlockParent as unknown as FrameNode, 'CENTER');
|