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,481 @@
|
|
|
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
|
+
import {
|
|
9
|
+
applyTruncation,
|
|
10
|
+
getTruncateFromClasses,
|
|
11
|
+
markForTruncation,
|
|
12
|
+
readTruncationMark,
|
|
13
|
+
} from '../src/text/text-truncate';
|
|
14
|
+
import { applyDeferredTruncationPass } from '../src/layout/text-truncate-pass';
|
|
15
|
+
import { applyTableColumnAlignment } from '../src/layout/table-layout';
|
|
16
|
+
import { findTruncateInInlineTree } from '../src/text/inline-text';
|
|
17
|
+
import type { NodeIR } from '../src/tailwind';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Regression: text-truncation primitives + deferred-truncation pass.
|
|
21
|
+
*
|
|
22
|
+
* The renderer marks each TextNode that came from `truncate` /
|
|
23
|
+
* `line-clamp-N` with `markForTruncation`. The actual ellipsis +
|
|
24
|
+
* resize (`applyTruncation`) runs either immediately when the
|
|
25
|
+
* effective max-width is already known at render time, or later from
|
|
26
|
+
* a deferred pass (e.g. table column-alignment) once the parent's
|
|
27
|
+
* width is final.
|
|
28
|
+
*
|
|
29
|
+
* Real-world repro: `RecentTradesCard` — the `$500.00 Withdrawn`
|
|
30
|
+
* collateral cell rendered as `$500.00 Withdraw` because the cell
|
|
31
|
+
* was visually clipped by the next column. With the deferred pass,
|
|
32
|
+
* any text inside such a cell that was authored with `truncate`
|
|
33
|
+
* gets `textTruncation = 'ENDING'` + the cell's content width.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Stub-frame + stub-text harness. Mirrors the table-column-alignment
|
|
38
|
+
// fixture's StubFrame; adds StubText so the deferred pass can walk
|
|
39
|
+
// the subtree and mutate text nodes.
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
interface StubBase {
|
|
43
|
+
name: string;
|
|
44
|
+
parent: StubFrame | null;
|
|
45
|
+
width: number;
|
|
46
|
+
height: number;
|
|
47
|
+
pluginData: Record<string, string>;
|
|
48
|
+
setPluginData(key: string, value: string): void;
|
|
49
|
+
getPluginData(key: string): string;
|
|
50
|
+
resize(w: number, h: number): void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface StubFrame extends StubBase {
|
|
54
|
+
type: 'FRAME';
|
|
55
|
+
layoutMode: 'NONE' | 'HORIZONTAL' | 'VERTICAL';
|
|
56
|
+
layoutPositioning: 'AUTO' | 'ABSOLUTE';
|
|
57
|
+
primaryAxisSizingMode: 'AUTO' | 'FIXED';
|
|
58
|
+
counterAxisSizingMode: 'AUTO' | 'FIXED';
|
|
59
|
+
layoutGrow: number;
|
|
60
|
+
paddingLeft: number;
|
|
61
|
+
paddingRight: number;
|
|
62
|
+
paddingTop: number;
|
|
63
|
+
paddingBottom: number;
|
|
64
|
+
children: (StubFrame | StubText)[];
|
|
65
|
+
appendChild(child: StubFrame | StubText): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface StubText extends StubBase {
|
|
69
|
+
type: 'TEXT';
|
|
70
|
+
fontSize: number | symbol;
|
|
71
|
+
lineHeight: { unit: 'PIXELS' | 'PERCENT' | 'AUTO'; value?: number };
|
|
72
|
+
textTruncation: 'DISABLED' | 'ENDING';
|
|
73
|
+
maxLines: number | null;
|
|
74
|
+
textAutoResize: 'NONE' | 'HEIGHT' | 'WIDTH_AND_HEIGHT';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function mkData(): { data: Record<string, string>; setPluginData: (k: string, v: string) => void; getPluginData: (k: string) => string } {
|
|
78
|
+
const data: Record<string, string> = {};
|
|
79
|
+
return {
|
|
80
|
+
data,
|
|
81
|
+
setPluginData(key, value) { data[key] = value; },
|
|
82
|
+
getPluginData(key) { return data[key] || ''; },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function mkFrame(name: string): StubFrame {
|
|
87
|
+
const d = mkData();
|
|
88
|
+
const f: StubFrame = {
|
|
89
|
+
type: 'FRAME',
|
|
90
|
+
name,
|
|
91
|
+
layoutMode: 'NONE',
|
|
92
|
+
layoutPositioning: 'AUTO',
|
|
93
|
+
primaryAxisSizingMode: 'AUTO',
|
|
94
|
+
counterAxisSizingMode: 'AUTO',
|
|
95
|
+
layoutGrow: 0,
|
|
96
|
+
width: 0,
|
|
97
|
+
height: 0,
|
|
98
|
+
paddingLeft: 0,
|
|
99
|
+
paddingRight: 0,
|
|
100
|
+
paddingTop: 0,
|
|
101
|
+
paddingBottom: 0,
|
|
102
|
+
parent: null,
|
|
103
|
+
children: [],
|
|
104
|
+
pluginData: d.data,
|
|
105
|
+
appendChild(child) {
|
|
106
|
+
child.parent = this;
|
|
107
|
+
this.children.push(child);
|
|
108
|
+
},
|
|
109
|
+
resize(w, h) {
|
|
110
|
+
this.width = w;
|
|
111
|
+
this.height = h;
|
|
112
|
+
},
|
|
113
|
+
setPluginData: d.setPluginData,
|
|
114
|
+
getPluginData: d.getPluginData,
|
|
115
|
+
};
|
|
116
|
+
return f;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function mkText(text: string, opts: {
|
|
120
|
+
width: number;
|
|
121
|
+
height?: number;
|
|
122
|
+
fontSize?: number;
|
|
123
|
+
lineHeight?: number;
|
|
124
|
+
}): StubText {
|
|
125
|
+
const d = mkData();
|
|
126
|
+
return {
|
|
127
|
+
type: 'TEXT',
|
|
128
|
+
name: text,
|
|
129
|
+
parent: null,
|
|
130
|
+
width: opts.width,
|
|
131
|
+
height: opts.height ?? (opts.lineHeight ?? Math.ceil((opts.fontSize ?? 14) * 1.5)),
|
|
132
|
+
fontSize: opts.fontSize ?? 14,
|
|
133
|
+
lineHeight: opts.lineHeight
|
|
134
|
+
? { unit: 'PIXELS', value: opts.lineHeight }
|
|
135
|
+
: { unit: 'AUTO' },
|
|
136
|
+
textTruncation: 'DISABLED',
|
|
137
|
+
maxLines: null,
|
|
138
|
+
textAutoResize: 'WIDTH_AND_HEIGHT',
|
|
139
|
+
pluginData: d.data,
|
|
140
|
+
setPluginData: d.setPluginData,
|
|
141
|
+
getPluginData: d.getPluginData,
|
|
142
|
+
resize(w, h) {
|
|
143
|
+
this.width = w;
|
|
144
|
+
this.height = h;
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const asScene = <T>(n: T) => n as unknown as SceneNode;
|
|
150
|
+
const asText = <T>(n: T) => n as unknown as TextNode;
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Cell 1 — mark / read roundtrip. Empty / missing / invalid all return null.
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
{
|
|
156
|
+
const t = mkText('hello', { width: 50 });
|
|
157
|
+
assert.equal(readTruncationMark(asText(t)), null, 'no mark → null');
|
|
158
|
+
|
|
159
|
+
markForTruncation(asText(t), 1);
|
|
160
|
+
assert.equal(readTruncationMark(asText(t)), 1, 'mark with 1 → 1');
|
|
161
|
+
|
|
162
|
+
markForTruncation(asText(t), 3);
|
|
163
|
+
assert.equal(readTruncationMark(asText(t)), 3, 'overwrite mark with 3 → 3');
|
|
164
|
+
|
|
165
|
+
markForTruncation(asText(t), 0);
|
|
166
|
+
assert.equal(readTruncationMark(asText(t)), 3, 'mark with 0 is rejected; old value kept');
|
|
167
|
+
|
|
168
|
+
markForTruncation(asText(t), -2);
|
|
169
|
+
assert.equal(readTruncationMark(asText(t)), 3, 'negative mark rejected; old value kept');
|
|
170
|
+
|
|
171
|
+
// Manual corruption: invalid stored value → null.
|
|
172
|
+
t.setPluginData('inkbridge:truncate-max-lines', 'not-a-number');
|
|
173
|
+
assert.equal(readTruncationMark(asText(t)), null, 'corrupted value → null');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Cell 2 — applyTruncation sets textTruncation, maxLines, and resizes.
|
|
178
|
+
// Lineheight defaults to fontSize*1.5 when not provided.
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
{
|
|
181
|
+
const t = mkText('truncate me', { width: 200, fontSize: 16 });
|
|
182
|
+
applyTruncation(asText(t), 100, 1);
|
|
183
|
+
assert.equal(t.textTruncation, 'ENDING', 'truncation enabled');
|
|
184
|
+
assert.equal(t.maxLines, 1, 'maxLines = 1');
|
|
185
|
+
assert.equal(t.width, 100, 'resized to maxWidth');
|
|
186
|
+
// lh default = ceil(16 * 1.5) = 24. height = max(24, 24*1) = 24.
|
|
187
|
+
assert.equal(t.height, 24, 'height = lineHeight × maxLines (default leading-normal)');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Cell 3 — applyTruncation uses provided lineHeight; line-clamp-N grows
|
|
192
|
+
// the height proportionally.
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
{
|
|
195
|
+
const t = mkText('multi-line', { width: 300, fontSize: 14 });
|
|
196
|
+
applyTruncation(asText(t), 120, 3, 20); // 3 lines × 20px = 60.
|
|
197
|
+
assert.equal(t.maxLines, 3);
|
|
198
|
+
assert.equal(t.width, 120);
|
|
199
|
+
assert.equal(t.height, 60, 'height = explicit lineHeight × maxLines');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Cell 4 — applyTruncation no-ops on invalid inputs.
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
{
|
|
206
|
+
const t = mkText('ignored', { width: 200 });
|
|
207
|
+
applyTruncation(asText(t), 0, 1); // bad width
|
|
208
|
+
assert.equal(t.textTruncation, 'DISABLED', 'maxWidth=0 → no-op');
|
|
209
|
+
applyTruncation(asText(t), 100, 0); // bad maxLines
|
|
210
|
+
assert.equal(t.textTruncation, 'DISABLED', 'maxLines=0 → no-op');
|
|
211
|
+
assert.equal(t.width, 200, 'unchanged');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Cell 5 — Deferred pass: marked text directly under container.
|
|
216
|
+
// Overflowing text gets truncated; unmarked text and fitting text are
|
|
217
|
+
// untouched.
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
{
|
|
220
|
+
const cell = mkFrame('td');
|
|
221
|
+
cell.layoutMode = 'HORIZONTAL';
|
|
222
|
+
cell.width = 100;
|
|
223
|
+
cell.height = 24;
|
|
224
|
+
|
|
225
|
+
const overflow = mkText('$500.00 Withdrawn', { width: 200 });
|
|
226
|
+
markForTruncation(asText(overflow), 1);
|
|
227
|
+
cell.appendChild(overflow);
|
|
228
|
+
|
|
229
|
+
applyDeferredTruncationPass(asScene(cell));
|
|
230
|
+
assert.equal(overflow.textTruncation, 'ENDING', 'overflowing marked text → truncated');
|
|
231
|
+
assert.equal(overflow.width, 100, 'resized to cell content width');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
{
|
|
235
|
+
const cell = mkFrame('td');
|
|
236
|
+
cell.width = 100;
|
|
237
|
+
const fits = mkText('short', { width: 40 });
|
|
238
|
+
markForTruncation(asText(fits), 1);
|
|
239
|
+
cell.appendChild(fits);
|
|
240
|
+
applyDeferredTruncationPass(asScene(cell));
|
|
241
|
+
assert.equal(fits.textTruncation, 'DISABLED', 'fitting text untouched');
|
|
242
|
+
assert.equal(fits.width, 40, 'fitting text keeps width');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
{
|
|
246
|
+
const cell = mkFrame('td');
|
|
247
|
+
cell.width = 100;
|
|
248
|
+
const unmarked = mkText('long unmarked text', { width: 200 });
|
|
249
|
+
// No mark — should be ignored even though it overflows.
|
|
250
|
+
cell.appendChild(unmarked);
|
|
251
|
+
applyDeferredTruncationPass(asScene(cell));
|
|
252
|
+
assert.equal(unmarked.textTruncation, 'DISABLED', 'unmarked text ignored');
|
|
253
|
+
assert.equal(unmarked.width, 200, 'unmarked text width preserved');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Cell 6 — Container paddings are subtracted from budget. A 100-wide
|
|
258
|
+
// cell with paddingLeft=8 + paddingRight=8 yields 84 of content width.
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
{
|
|
261
|
+
const cell = mkFrame('td');
|
|
262
|
+
cell.width = 100;
|
|
263
|
+
cell.paddingLeft = 8;
|
|
264
|
+
cell.paddingRight = 8;
|
|
265
|
+
const t = mkText('overflow', { width: 90 });
|
|
266
|
+
markForTruncation(asText(t), 1);
|
|
267
|
+
cell.appendChild(t);
|
|
268
|
+
applyDeferredTruncationPass(asScene(cell));
|
|
269
|
+
assert.equal(t.width, 84, 'text resized to 100 - 8 - 8 = 84');
|
|
270
|
+
assert.equal(t.textTruncation, 'ENDING');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
// Cell 7 — Intermediate wrapper frame's paddings are subtracted as we
|
|
275
|
+
// descend, so a deeply-nested marked text node sees its true budget.
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
{
|
|
278
|
+
const cell = mkFrame('td');
|
|
279
|
+
cell.width = 200;
|
|
280
|
+
cell.paddingLeft = 10;
|
|
281
|
+
cell.paddingRight = 10;
|
|
282
|
+
// Budget at cell's children: 180.
|
|
283
|
+
|
|
284
|
+
const wrapper = mkFrame('div');
|
|
285
|
+
wrapper.paddingLeft = 5;
|
|
286
|
+
wrapper.paddingRight = 5;
|
|
287
|
+
cell.appendChild(wrapper);
|
|
288
|
+
// Budget at wrapper's children: 180 - 5 - 5 = 170.
|
|
289
|
+
|
|
290
|
+
const t = mkText('deep text', { width: 250 });
|
|
291
|
+
markForTruncation(asText(t), 1);
|
|
292
|
+
wrapper.appendChild(t);
|
|
293
|
+
|
|
294
|
+
applyDeferredTruncationPass(asScene(cell));
|
|
295
|
+
assert.equal(t.width, 170, 'budget = cellContent - wrapperPaddings = 170');
|
|
296
|
+
assert.equal(t.textTruncation, 'ENDING');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// Cell 8 — Idempotency: running the pass twice doesn't re-truncate a
|
|
301
|
+
// node that now fits exactly. Confirms the > available + 0.5 tolerance.
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
{
|
|
304
|
+
const cell = mkFrame('td');
|
|
305
|
+
cell.width = 100;
|
|
306
|
+
const t = mkText('x', { width: 200 });
|
|
307
|
+
markForTruncation(asText(t), 1);
|
|
308
|
+
cell.appendChild(t);
|
|
309
|
+
|
|
310
|
+
const first = applyDeferredTruncationPass(asScene(cell));
|
|
311
|
+
assert.equal(first, 1, 'first pass applied to 1 node');
|
|
312
|
+
const second = applyDeferredTruncationPass(asScene(cell));
|
|
313
|
+
assert.equal(second, 0, 'second pass is a no-op (already fits)');
|
|
314
|
+
assert.equal(t.width, 100, 'width unchanged on second pass');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
// Cell 9 — End-to-end via applyTableColumnAlignment: a card-anchored
|
|
319
|
+
// table where one cell's marked text overflows the assigned column
|
|
320
|
+
// width. The column-alignment pass shrinks the column to fit the
|
|
321
|
+
// card, then the deferred-truncation pass clips the text.
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
{
|
|
324
|
+
// Parent card at FIXED 200; two-column table whose natural sum (300)
|
|
325
|
+
// exceeds available 200.
|
|
326
|
+
const card = mkFrame('div');
|
|
327
|
+
card.layoutMode = 'VERTICAL';
|
|
328
|
+
card.counterAxisSizingMode = 'FIXED';
|
|
329
|
+
card.width = 200;
|
|
330
|
+
card.height = 60;
|
|
331
|
+
|
|
332
|
+
function cellWith(textWidth: number, mark: boolean): { cell: StubFrame; text: StubText } {
|
|
333
|
+
const c = mkFrame('td');
|
|
334
|
+
c.layoutMode = 'HORIZONTAL';
|
|
335
|
+
c.layoutGrow = 1;
|
|
336
|
+
c.width = textWidth;
|
|
337
|
+
c.height = 20;
|
|
338
|
+
c.setPluginData('inkbridge:col-span', '1');
|
|
339
|
+
const t = mkText('content', { width: textWidth });
|
|
340
|
+
if (mark) markForTruncation(asText(t), 1);
|
|
341
|
+
c.appendChild(t);
|
|
342
|
+
return { cell: c, text: t };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const row1Col0 = cellWith(100, true); // marked, overflowing column
|
|
346
|
+
const row1Col1 = cellWith(200, false); // unmarked
|
|
347
|
+
// applyTableColumnAlignment skips single-row tables. Add a header
|
|
348
|
+
// row so the pass actually runs.
|
|
349
|
+
const headerCol0 = cellWith(50, false);
|
|
350
|
+
const headerCol1 = cellWith(80, false);
|
|
351
|
+
|
|
352
|
+
const tableFrame = mkFrame('table');
|
|
353
|
+
tableFrame.layoutMode = 'VERTICAL';
|
|
354
|
+
const thead = mkFrame('thead');
|
|
355
|
+
thead.layoutMode = 'VERTICAL';
|
|
356
|
+
const headerTr = mkFrame('tr');
|
|
357
|
+
headerTr.layoutMode = 'HORIZONTAL';
|
|
358
|
+
headerTr.appendChild(headerCol0.cell);
|
|
359
|
+
headerTr.appendChild(headerCol1.cell);
|
|
360
|
+
thead.appendChild(headerTr);
|
|
361
|
+
const tbody = mkFrame('tbody');
|
|
362
|
+
tbody.layoutMode = 'VERTICAL';
|
|
363
|
+
const tr = mkFrame('tr');
|
|
364
|
+
tr.layoutMode = 'HORIZONTAL';
|
|
365
|
+
tr.appendChild(row1Col0.cell);
|
|
366
|
+
tr.appendChild(row1Col1.cell);
|
|
367
|
+
tbody.appendChild(tr);
|
|
368
|
+
tableFrame.appendChild(thead);
|
|
369
|
+
tableFrame.appendChild(tbody);
|
|
370
|
+
card.appendChild(tableFrame);
|
|
371
|
+
|
|
372
|
+
applyTableColumnAlignment(asScene(card));
|
|
373
|
+
|
|
374
|
+
// Natural sum = 300, available = 200, scale = 2/3.
|
|
375
|
+
// Col 0: floor(100 * 2/3) = 66. Col 1: floor(200 * 2/3) = 133.
|
|
376
|
+
assert.equal(row1Col0.cell.width, 66, 'col 0 scaled down to 66');
|
|
377
|
+
assert.equal(row1Col1.cell.width, 133, 'col 1 scaled down to 133');
|
|
378
|
+
|
|
379
|
+
// Marked text inside col 0 was 100 wide; the cell now is 66.
|
|
380
|
+
// Deferred pass should kick in and clip it.
|
|
381
|
+
assert.equal(row1Col0.text.textTruncation, 'ENDING', 'marked overflow → ENDING');
|
|
382
|
+
assert.equal(row1Col0.text.width, 66, 'marked text resized to cell width');
|
|
383
|
+
|
|
384
|
+
// Unmarked text in col 1: was 200 wide, cell is now 133. Pass
|
|
385
|
+
// ignores it because no mark.
|
|
386
|
+
assert.equal(row1Col1.text.textTruncation, 'DISABLED', 'unmarked text untouched');
|
|
387
|
+
assert.equal(row1Col1.text.width, 200, 'unmarked text not resized by truncation pass');
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
// Cell 9b — Inline-collapse tree walker: the RecentTradesCard pattern
|
|
392
|
+
// <td><span class="block max-w-[9.5rem] truncate">…</span></td>
|
|
393
|
+
// rolls through `createInlineTextNode`, which calls
|
|
394
|
+
// `findTruncateInInlineTree` to decide whether to mark the merged
|
|
395
|
+
// TextNode. The walker must find a truncate anywhere in the tree
|
|
396
|
+
// (outer node, immediate child, deeper descendant).
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
{
|
|
399
|
+
function elem(tag: string, classes: string[], children: NodeIR[]): NodeIR {
|
|
400
|
+
return {
|
|
401
|
+
kind: 'element',
|
|
402
|
+
tagName: tag,
|
|
403
|
+
tagLower: tag,
|
|
404
|
+
classes,
|
|
405
|
+
props: {},
|
|
406
|
+
children,
|
|
407
|
+
} as NodeIR;
|
|
408
|
+
}
|
|
409
|
+
function text(value: string): NodeIR {
|
|
410
|
+
return { kind: 'text', text: value } as NodeIR;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Outer alone has truncate.
|
|
414
|
+
{
|
|
415
|
+
const tree = elem('p', ['truncate'], [text('hello')]);
|
|
416
|
+
assert.deepEqual(findTruncateInInlineTree(tree), { maxLines: 1 }, 'outer .truncate');
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Inner child has truncate (real-world RecentTradesCard shape).
|
|
420
|
+
{
|
|
421
|
+
const inner = elem('span', ['block', 'max-w-[9.5rem]', 'truncate'], [text('$500.00')]);
|
|
422
|
+
const td = elem('td', ['min-w-0'], [inner]);
|
|
423
|
+
assert.deepEqual(findTruncateInInlineTree(td), { maxLines: 1 }, 'inner span .truncate inside td');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Deeper nesting: td → span → b → truncate.
|
|
427
|
+
{
|
|
428
|
+
const bold = elem('b', ['line-clamp-2'], [text('long')]);
|
|
429
|
+
const span = elem('span', [], [bold]);
|
|
430
|
+
const td = elem('td', [], [span]);
|
|
431
|
+
assert.deepEqual(findTruncateInInlineTree(td), { maxLines: 2 }, 'deep-nested line-clamp-2');
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// No truncate anywhere → null.
|
|
435
|
+
{
|
|
436
|
+
const tree = elem('td', ['min-w-0'], [
|
|
437
|
+
elem('span', ['text-sm'], [text('plain')]),
|
|
438
|
+
]);
|
|
439
|
+
assert.equal(findTruncateInInlineTree(tree), null, 'no truncate → null');
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// childrenOverride takes precedence over node.children — used
|
|
443
|
+
// by the hidden-children filter to drop responsive-hidden spans
|
|
444
|
+
// before truncate detection.
|
|
445
|
+
{
|
|
446
|
+
const visibleSpan = elem('span', ['truncate'], [text('visible')]);
|
|
447
|
+
const hiddenSpan = elem('span', ['truncate', 'hidden'], [text('hidden')]);
|
|
448
|
+
const td = elem('td', [], [hiddenSpan, visibleSpan]);
|
|
449
|
+
// With override pointing to only the visible span, the walker
|
|
450
|
+
// still picks the truncate up — but from the override list.
|
|
451
|
+
assert.deepEqual(
|
|
452
|
+
findTruncateInInlineTree(td, [visibleSpan]),
|
|
453
|
+
{ maxLines: 1 },
|
|
454
|
+
'childrenOverride list is walked',
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
// Cell 10 — getTruncateFromClasses: single source of truth for parsing.
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
{
|
|
463
|
+
assert.equal(getTruncateFromClasses([]), null, 'empty → null');
|
|
464
|
+
assert.equal(getTruncateFromClasses(['flex', 'gap-2']), null, 'no truncate / clamp → null');
|
|
465
|
+
assert.deepEqual(getTruncateFromClasses(['truncate']), { maxLines: 1 }, 'truncate → 1 line');
|
|
466
|
+
assert.deepEqual(
|
|
467
|
+
getTruncateFromClasses(['block', 'max-w-[9.5rem]', 'truncate']),
|
|
468
|
+
{ maxLines: 1 },
|
|
469
|
+
'truncate among other classes → 1 line',
|
|
470
|
+
);
|
|
471
|
+
assert.deepEqual(getTruncateFromClasses(['line-clamp-3']), { maxLines: 3 }, 'line-clamp-3 → 3');
|
|
472
|
+
assert.deepEqual(
|
|
473
|
+
getTruncateFromClasses(['truncate', 'line-clamp-5']),
|
|
474
|
+
{ maxLines: 1 },
|
|
475
|
+
'truncate wins when both present (matches Tailwind: truncate is later in CSS)',
|
|
476
|
+
);
|
|
477
|
+
assert.equal(getTruncateFromClasses(['line-clamp-0']), null, 'clamp-0 rejected (n>0 only)');
|
|
478
|
+
assert.equal(getTruncateFromClasses(['line-clamp-foo']), null, 'non-numeric clamp value → null');
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
console.log('text-truncate-regression: ok (10 cells)');
|
package/scanner/types.ts
CHANGED
|
@@ -38,6 +38,19 @@ export interface StoryInfo {
|
|
|
38
38
|
layoutClasses?: string[];
|
|
39
39
|
/** Full JSX tree for recursive rendering */
|
|
40
40
|
jsxTree?: JsxNode;
|
|
41
|
+
/**
|
|
42
|
+
* Opt-in/opt-out for responsive-breakpoint frames. When unset, the
|
|
43
|
+
* plugin renders responsive frames only for the canonical "Default"
|
|
44
|
+
* story (or first export) of each component. Setting `true` forces
|
|
45
|
+
* responsive frames for this story; `false` suppresses them.
|
|
46
|
+
*
|
|
47
|
+
* Source: Storybook story export's `parameters.inkbridge.responsive`:
|
|
48
|
+
* export const WithMarkerLegend: Story = {
|
|
49
|
+
* args: { ... },
|
|
50
|
+
* parameters: { inkbridge: { responsive: true } },
|
|
51
|
+
* };
|
|
52
|
+
*/
|
|
53
|
+
responsive?: boolean;
|
|
41
54
|
}
|
|
42
55
|
|
|
43
56
|
/**
|
|
@@ -4,7 +4,7 @@ import { getComponentDef } from './component-defs';
|
|
|
4
4
|
import type { ComponentAnalysis } from '../../scanner/types';
|
|
5
5
|
import type { ComponentDef } from './scanner-types';
|
|
6
6
|
import { tailwindClassesToStyle, applyTailwindStylesToFrame } from '../tailwind';
|
|
7
|
-
import {
|
|
7
|
+
import { applyTokenColor, pxFromSizeToken } from '../tokens';
|
|
8
8
|
import { createTextNode } from '../text';
|
|
9
9
|
import { extractStatesFromClasses, mergeStatesWithDefinition, type StateInfo } from '../tailwind';
|
|
10
10
|
import { getRingInfoFromClasses, markRingNode, applyRingIfPossible } from '../layout';
|
|
@@ -114,29 +114,19 @@ export function createCVAComponentSet(parent: FrameNode | PageNode, def: Compone
|
|
|
114
114
|
// Get style for this state (base + state overrides)
|
|
115
115
|
const style = tailwindClassesToStyle(stateClasses, 'default', colorGroup);
|
|
116
116
|
|
|
117
|
-
// Apply background - try variable binding first, fall back to raw color
|
|
118
117
|
if (style.bg) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
comp.fills = nextFills;
|
|
125
|
-
}
|
|
126
|
-
} else {
|
|
127
|
-
const bg = parseColor(style.bg);
|
|
128
|
-
const bgOpacity = style.bgOpacity != null ? style.bgOpacity : (bg.a == null ? 1 : bg.a);
|
|
129
|
-
comp.fills = [{ type: 'SOLID', color: { r: bg.r, g: bg.g, b: bg.b }, opacity: bgOpacity }];
|
|
130
|
-
}
|
|
118
|
+
applyTokenColor(comp, 'fill', {
|
|
119
|
+
token: style.bgToken,
|
|
120
|
+
value: style.bg,
|
|
121
|
+
opacity: style.bgOpacity,
|
|
122
|
+
}, theme);
|
|
131
123
|
}
|
|
132
|
-
|
|
133
|
-
// Apply border - try variable binding first, fall back to raw color
|
|
134
124
|
if (style.border) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
125
|
+
applyTokenColor(comp, 'stroke', {
|
|
126
|
+
token: style.borderToken,
|
|
127
|
+
value: style.border,
|
|
128
|
+
opacity: style.borderOpacity,
|
|
129
|
+
}, theme);
|
|
140
130
|
comp.strokeWeight = 1;
|
|
141
131
|
}
|
|
142
132
|
|
|
@@ -222,26 +212,19 @@ export function createStateComponentSet(parent: FrameNode | PageNode, def: Compo
|
|
|
222
212
|
const style = tailwindClassesToStyle(stateClasses, 'default', colorGroup);
|
|
223
213
|
|
|
224
214
|
if (style.bg) {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
comp.fills = nextFills;
|
|
231
|
-
}
|
|
232
|
-
} else {
|
|
233
|
-
const bg = parseColor(style.bg);
|
|
234
|
-
const bgOpacity = style.bgOpacity != null ? style.bgOpacity : (bg.a == null ? 1 : bg.a);
|
|
235
|
-
comp.fills = [{ type: 'SOLID', color: { r: bg.r, g: bg.g, b: bg.b }, opacity: bgOpacity }];
|
|
236
|
-
}
|
|
215
|
+
applyTokenColor(comp, 'fill', {
|
|
216
|
+
token: style.bgToken,
|
|
217
|
+
value: style.bg,
|
|
218
|
+
opacity: style.bgOpacity,
|
|
219
|
+
}, theme);
|
|
237
220
|
}
|
|
238
221
|
|
|
239
222
|
if (style.border) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
223
|
+
applyTokenColor(comp, 'stroke', {
|
|
224
|
+
token: style.borderToken,
|
|
225
|
+
value: style.border,
|
|
226
|
+
opacity: style.borderOpacity,
|
|
227
|
+
}, theme);
|
|
245
228
|
comp.strokeWeight = 1;
|
|
246
229
|
} else {
|
|
247
230
|
let hasBorderWidth = false;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { splitClassName, applyTailwindStylesToFrame, tailwindClassesToStyle, type TailwindStyle } from '../tailwind';
|
|
2
|
-
import {
|
|
2
|
+
import { applyTokenColor, parseColor } from '../tokens';
|
|
3
3
|
import { createTextNode } from '../text';
|
|
4
4
|
import { getRingInfoFromClasses, markRingNode, applyRingIfPossible, enforceFixedBoxSizingAfterLayout } from '../layout';
|
|
5
5
|
import { MASTER_ICON_NAME_KEY } from '../components/component-instance';
|
|
@@ -38,26 +38,19 @@ export function applyStyleToFrame(frame: FrameNode, style: TailwindStyle, theme:
|
|
|
38
38
|
if (!style) return;
|
|
39
39
|
|
|
40
40
|
if (style.bg) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
frame.fills = nextFills;
|
|
47
|
-
}
|
|
48
|
-
} else {
|
|
49
|
-
const bg = parseColor(style.bg);
|
|
50
|
-
const bgOpacity = style.bgOpacity != null ? style.bgOpacity : (bg.a == null ? 1 : bg.a);
|
|
51
|
-
frame.fills = [{ type: 'SOLID', color: { r: bg.r, g: bg.g, b: bg.b }, opacity: bgOpacity }];
|
|
52
|
-
}
|
|
41
|
+
applyTokenColor(frame, 'fill', {
|
|
42
|
+
token: style.bgToken,
|
|
43
|
+
value: style.bg,
|
|
44
|
+
opacity: style.bgOpacity,
|
|
45
|
+
}, theme);
|
|
53
46
|
}
|
|
54
47
|
|
|
55
48
|
if (style.border) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
49
|
+
applyTokenColor(frame, 'stroke', {
|
|
50
|
+
token: style.borderToken,
|
|
51
|
+
value: style.border,
|
|
52
|
+
opacity: style.borderOpacity,
|
|
53
|
+
}, theme);
|
|
61
54
|
frame.strokeWeight = 1;
|
|
62
55
|
}
|
|
63
56
|
|