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.
Files changed (69) hide show
  1. package/README.md +29 -0
  2. package/code.js +15 -15
  3. package/manifest.json +1 -2
  4. package/package.json +40 -22
  5. package/scanner/border-dash-pattern-regression.ts +163 -0
  6. package/scanner/child-sizing-matrix-regression.ts +9 -0
  7. package/scanner/cli.ts +21 -5
  8. package/scanner/component-scanner.ts +1333 -77
  9. package/scanner/conditional-map-branch-regression.ts +180 -0
  10. package/scanner/css-token-reader.ts +66 -5
  11. package/scanner/dialog-content-gate-regression.ts +195 -0
  12. package/scanner/expression-evaluator-regression.ts +432 -0
  13. package/scanner/framework-adapter-shadcn-regression.ts +157 -1
  14. package/scanner/hidden-check-drift-regression.ts +125 -0
  15. package/scanner/horizontal-text-shrink-regression.ts +230 -0
  16. package/scanner/imported-array-map-regression.ts +195 -0
  17. package/scanner/inline-flex-regression.ts +5 -0
  18. package/scanner/intrinsic-sizing-regression.ts +333 -0
  19. package/scanner/portal-class-strip-regression.ts +109 -0
  20. package/scanner/responsive-hidden-inline-regression.ts +226 -0
  21. package/scanner/responsive-opt-in-regression.ts +212 -0
  22. package/scanner/select-root-flatten-regression.ts +314 -0
  23. package/scanner/space-between-single-child-regression.ts +163 -0
  24. package/scanner/story-args-resolution-regression.ts +311 -0
  25. package/scanner/story-dimensioning-regression.ts +76 -1
  26. package/scanner/style-map.ts +57 -0
  27. package/scanner/table-column-alignment-regression.ts +355 -0
  28. package/scanner/ternary-fragment-branch-regression.ts +196 -0
  29. package/scanner/text-truncate-regression.ts +481 -0
  30. package/scanner/types.ts +13 -0
  31. package/src/components/component-gen.ts +21 -38
  32. package/src/design-system/cva-master.ts +11 -18
  33. package/src/design-system/design-system.ts +36 -7
  34. package/src/design-system/frame-stabilizers.ts +109 -12
  35. package/src/design-system/preview-builder.ts +38 -0
  36. package/src/design-system/selectable-state.ts +8 -1
  37. package/src/design-system/story-builder.ts +62 -32
  38. package/src/design-system/story-dimensioning.ts +14 -3
  39. package/src/design-system/tag-predicates.ts +8 -0
  40. package/src/design-system/typography.ts +26 -0
  41. package/src/design-system/ui-builder.ts +188 -60
  42. package/src/effects/icon-builder.ts +8 -0
  43. package/src/framework-adapters/shadcn.ts +113 -0
  44. package/src/github/github.ts +22 -4
  45. package/src/layout/index.ts +4 -0
  46. package/src/layout/intrinsic-applier.ts +105 -0
  47. package/src/layout/intrinsic-sizing.ts +183 -0
  48. package/src/layout/layout-parser.ts +36 -0
  49. package/src/layout/parser/layout-mode.ts +14 -4
  50. package/src/layout/table-layout.ts +271 -0
  51. package/src/layout/text-truncate-pass.ts +151 -0
  52. package/src/layout/width-solver.ts +63 -17
  53. package/src/main.ts +37 -124
  54. package/src/plugin/config.ts +21 -0
  55. package/src/plugin/packs/pack-provider.ts +20 -4
  56. package/src/plugin/packs/packs.ts +14 -0
  57. package/src/render-engine-version.ts +1 -1
  58. package/src/tailwind/jsx-utils.ts +39 -0
  59. package/src/tailwind/node-ir.ts +8 -1
  60. package/src/tailwind/responsive-analyzer.ts +57 -3
  61. package/src/tailwind/tailwind.ts +344 -51
  62. package/src/text/index.ts +1 -0
  63. package/src/text/inline-text.ts +112 -12
  64. package/src/text/text-builder.ts +2 -2
  65. package/src/text/text-truncate.ts +101 -0
  66. package/src/tokens/tokens.ts +107 -16
  67. package/src/tokens/variables.ts +203 -46
  68. package/templates/scan-components-route.ts +8 -0
  69. 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 { bindColorVariable, pxFromSizeToken } from '../tokens';
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
- const bgBound = style.bgToken && bindColorVariable(comp, style.bgToken, 'fill', theme);
120
- if (bgBound) {
121
- if (style.bgOpacity != null && Array.isArray(comp.fills) && comp.fills.length > 0) {
122
- const nextFills = JSON.parse(JSON.stringify(comp.fills));
123
- nextFills[0].opacity = style.bgOpacity;
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
- const borderBound = style.borderToken && bindColorVariable(comp, style.borderToken, 'stroke', theme);
136
- if (!borderBound) {
137
- const borderColor = parseColor(style.border);
138
- comp.strokes = [{ type: 'SOLID', color: { r: borderColor.r, g: borderColor.g, b: borderColor.b } }];
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
- const bgBound = style.bgToken && bindColorVariable(comp, style.bgToken, 'fill', theme);
226
- if (bgBound) {
227
- if (style.bgOpacity != null && Array.isArray(comp.fills) && comp.fills.length > 0) {
228
- const nextFills = JSON.parse(JSON.stringify(comp.fills));
229
- nextFills[0].opacity = style.bgOpacity;
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
- const borderBound = style.borderToken && bindColorVariable(comp, style.borderToken, 'stroke', theme);
241
- if (!borderBound) {
242
- const borderColor = parseColor(style.border);
243
- comp.strokes = [{ type: 'SOLID', color: { r: borderColor.r, g: borderColor.g, b: borderColor.b } }];
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 { parseColor, bindColorVariable } from '../tokens';
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
- const bgBound = style.bgToken && bindColorVariable(frame, style.bgToken, 'fill', theme);
42
- if (bgBound) {
43
- if (style.bgOpacity != null && Array.isArray(frame.fills) && frame.fills.length > 0) {
44
- const nextFills = JSON.parse(JSON.stringify(frame.fills));
45
- nextFills[0].opacity = style.bgOpacity;
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
- const borderBound = style.borderToken && bindColorVariable(frame, style.borderToken, 'stroke', theme);
57
- if (!borderBound) {
58
- const borderColor = parseColor(style.border);
59
- frame.strokes = [{ type: 'SOLID', color: { r: borderColor.r, g: borderColor.g, b: borderColor.b } }];
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