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
@@ -1,4 +1,25 @@
1
- import { TOKENS } from './tokens';
1
+ import { TOKENS, extractFontName, isConsumerOwnedToken } from './tokens';
2
+
3
+ /**
4
+ * Filter an entry map down to the consumer-owned keys for a given
5
+ * theme/group. Used by every demoFrame* to drop Tailwind defaults that
6
+ * leaked in through `@import "tailwindcss"` (e.g. `serif: Cambria`,
7
+ * `spacing.base: 0.25rem`, the full breakpoint scale). When no
8
+ * consumer-only scan is available (pre-scan, embedded fallback, older
9
+ * CLI output), `isConsumerOwnedToken` returns true for all keys —
10
+ * preserves the pre-Concern-#2 behaviour as a graceful fallback.
11
+ */
12
+ function filterConsumerOwned(
13
+ theme: string,
14
+ group: string,
15
+ entries: Record<string, string>,
16
+ ): Record<string, string> {
17
+ const out: Record<string, string> = {};
18
+ for (const key of Object.keys(entries)) {
19
+ if (isConsumerOwnedToken(theme, group, key)) out[key] = entries[key];
20
+ }
21
+ return out;
22
+ }
2
23
  import { parseColor, colorToLabel, debug, normalizeThemeName, normalizeGroupName, normalizeSizeValue } from './colors';
3
24
 
4
25
  // ---------------------------------------------------------------------------
@@ -902,6 +923,68 @@ export function createOrUpdateStylesFallback(): void {
902
923
  /**
903
924
  * Bind a frame's fill to a color variable (semantic token).
904
925
  */
926
+ /**
927
+ * Universal color application for any token slot — `fill` or `stroke`,
928
+ * for any node that has the corresponding property (frames, text). Tries
929
+ * variable binding first (so the result tracks theme mode changes), then
930
+ * falls back to a raw color value.
931
+ *
932
+ * Single source of truth that collapses the previously-duplicated bg-apply
933
+ * and border-apply blocks in tailwind.ts, cva-master.ts, and
934
+ * component-gen.ts. Adding a new color slot (e.g. focus-ring overlay,
935
+ * decorative SVG fill) becomes a one-liner against this helper instead of
936
+ * another copy-pasted variable-binding block.
937
+ *
938
+ * Returns `true` when EITHER the variable bound OR the literal color was
939
+ * applied — callers that need to schedule side effects (e.g. `strokeWeight
940
+ * = 1` only when a stroke was actually painted) gate on the return value.
941
+ */
942
+ export function applyTokenColor(
943
+ node: SceneNode,
944
+ slot: 'fill' | 'stroke',
945
+ intent: { token?: string | null; value?: string | null; opacity?: number | null },
946
+ theme?: string,
947
+ ): boolean {
948
+ // Figma's plugin API silently normalises a bound paint's `opacity` to 1
949
+ // on `node.fills = […]` reassignment when `boundVariables.color` is set.
950
+ // No combination of bake-before-bind, bake-after-bind, or Object.assign
951
+ // clones survives that normalisation (verified with traces of
952
+ // inputOpacity 0.6 → storedOpacity 1 on every variant). The only path
953
+ // that preserves partial alpha is to skip variable binding entirely and
954
+ // write a literal SOLID paint with `opacity` set. Trade-off: a theme /
955
+ // mode switch won't re-resolve the color for that fill. Acceptable
956
+ // because the consumer's `/N` modifier on the class was a compile-time
957
+ // decision in CSS too — Tailwind bakes the alpha at build time, not at
958
+ // theme-switch time.
959
+ const wantsPartialAlpha = intent.opacity != null && intent.opacity < 1;
960
+ if (intent.token && !wantsPartialAlpha) {
961
+ if (bindColorVariable(node, intent.token, slot, theme)) return true;
962
+ }
963
+ if (!intent.value) return false;
964
+ const color = parseColor(intent.value);
965
+ const opacity = intent.opacity != null
966
+ ? Math.max(0, Math.min(1, intent.opacity))
967
+ : (color.a == null ? 1 : color.a);
968
+ const paint: SolidPaint = {
969
+ type: 'SOLID',
970
+ color: { r: color.r, g: color.g, b: color.b },
971
+ opacity,
972
+ };
973
+ try {
974
+ if (slot === 'fill' && 'fills' in node) {
975
+ node.fills = [paint];
976
+ return true;
977
+ }
978
+ if (slot === 'stroke' && 'strokes' in node) {
979
+ node.strokes = [paint];
980
+ return true;
981
+ }
982
+ } catch (_err) {
983
+ // Some node types reject the assignment; bail silently.
984
+ }
985
+ return false;
986
+ }
987
+
905
988
  export function bindColorVariable(node: SceneNode, tokenKey: string, fillOrStroke: string, theme?: string): boolean {
906
989
  const resolvedTheme =
907
990
  (theme && (_themeColorVariables[theme] || _variableModeIds[theme])) ? theme :
@@ -914,20 +997,20 @@ export function bindColorVariable(node: SceneNode, tokenKey: string, fillOrStrok
914
997
  if (!variable) return false;
915
998
 
916
999
  try {
1000
+ // Bound paints render at opacity 1 always — Figma's plugin API
1001
+ // silently normalises a bound paint's `opacity` to 1 on `node.fills
1002
+ // = [paint]` reassignment regardless of what the input paint
1003
+ // carried. Callers that need partial alpha must skip binding and
1004
+ // use a literal SOLID paint instead (see `applyTokenColor`'s
1005
+ // `wantsPartialAlpha` short-circuit).
917
1006
  if (fillOrStroke === 'fill' && 'fills' in node) {
918
- const fills: Paint[] = JSON.parse(JSON.stringify(node.fills || []));
919
- if (fills.length === 0) {
920
- fills.push({ type: 'SOLID', color: { r: 1, g: 1, b: 1 }, opacity: 1 });
921
- }
922
- fills[0] = figma.variables.setBoundVariableForPaint(fills[0] as SolidPaint, 'color', variable);
923
- node.fills = fills;
1007
+ const seed: SolidPaint = { type: 'SOLID', color: { r: 1, g: 1, b: 1 } };
1008
+ const bound = figma.variables.setBoundVariableForPaint(seed, 'color', variable);
1009
+ node.fills = [bound];
924
1010
  } else if (fillOrStroke === 'stroke' && 'strokes' in node) {
925
- const strokes: Paint[] = JSON.parse(JSON.stringify(node.strokes || []));
926
- if (strokes.length === 0) {
927
- strokes.push({ type: 'SOLID', color: { r: 0.9, g: 0.9, b: 0.9 } });
928
- }
929
- strokes[0] = figma.variables.setBoundVariableForPaint(strokes[0] as SolidPaint, 'color', variable);
930
- node.strokes = strokes;
1011
+ const seed: SolidPaint = { type: 'SOLID', color: { r: 0.9, g: 0.9, b: 0.9 } };
1012
+ const bound = figma.variables.setBoundVariableForPaint(seed, 'color', variable);
1013
+ node.strokes = [bound];
931
1014
  }
932
1015
  return true;
933
1016
  } catch (e) {
@@ -1135,11 +1218,58 @@ function makeSectionFrame(name: string): FrameNode {
1135
1218
  return frame;
1136
1219
  }
1137
1220
 
1221
+ // Column layout for the color demo grid:
1222
+ // * Up to MAX_COLOR_COLUMNS columns side-by-side.
1223
+ // * Minimum COLOR_MIN_ROWS_PER_COLUMN rows per column so small themes
1224
+ // don't render as a row of tiny single-entry columns.
1225
+ // * Once the column cap is hit, additional entries extend the per-column
1226
+ // height instead of opening a 4th column — keeps the grid visually
1227
+ // bounded on the Design System page.
1228
+ //
1229
+ // Effective rows-per-column = max(MIN, ceil(entries / MAX_COLUMNS)). So:
1230
+ // 12 entries → 1 column ×12 rows (under MIN, single column)
1231
+ // 30 entries → 2 columns ×15 rows (rounded up to MIN)
1232
+ // 45 entries → 3 columns ×15 rows
1233
+ // 60 entries → 3 columns ×20 rows (column cap holds, rows grow)
1234
+ const MAX_COLOR_COLUMNS = 3;
1235
+ const COLOR_MIN_ROWS_PER_COLUMN = 15;
1236
+
1138
1237
  export function demoFrameColors(theme: string): FrameNode {
1139
1238
  const frame = makeSectionFrame(theme.toUpperCase() + ' Colors');
1140
1239
  frame.appendChild(makeSectionTitle(getThemeDisplayName(theme)));
1141
- const col: Record<string, string> = getThemeGroup(theme, 'color');
1142
- for (const [key, val] of Object.entries(col)) {
1240
+
1241
+ // Inner horizontal grid: one VERTICAL column per slice of
1242
+ // rowsPerColumn entries. Outer section frame stays VERTICAL so
1243
+ // the title remains above the column row.
1244
+ const grid = figma.createFrame();
1245
+ grid.name = 'color-grid';
1246
+ grid.layoutMode = 'HORIZONTAL';
1247
+ grid.primaryAxisSizingMode = 'AUTO';
1248
+ grid.counterAxisSizingMode = 'AUTO';
1249
+ grid.itemSpacing = 32;
1250
+ grid.counterAxisAlignItems = 'MIN';
1251
+ grid.fills = [];
1252
+ grid.strokes = [];
1253
+
1254
+ const entries = Object.entries(filterConsumerOwned(theme, 'color', getThemeGroup(theme, 'color')));
1255
+ const rowsPerColumn = Math.max(
1256
+ COLOR_MIN_ROWS_PER_COLUMN,
1257
+ Math.ceil(entries.length / MAX_COLOR_COLUMNS),
1258
+ );
1259
+ let column: FrameNode | null = null;
1260
+ for (let i = 0; i < entries.length; i++) {
1261
+ if (i % rowsPerColumn === 0) {
1262
+ column = figma.createFrame();
1263
+ column.name = 'color-column-' + Math.floor(i / rowsPerColumn);
1264
+ column.layoutMode = 'VERTICAL';
1265
+ column.primaryAxisSizingMode = 'AUTO';
1266
+ column.counterAxisSizingMode = 'AUTO';
1267
+ column.itemSpacing = 12;
1268
+ column.fills = [];
1269
+ column.strokes = [];
1270
+ grid.appendChild(column);
1271
+ }
1272
+ const [key, val] = entries[i];
1143
1273
  const row = figma.createFrame();
1144
1274
  row.layoutMode = 'HORIZONTAL';
1145
1275
  row.primaryAxisSizingMode = 'AUTO';
@@ -1157,12 +1287,19 @@ export function demoFrameColors(theme: string): FrameNode {
1157
1287
  const label = makeBodyText(key + ' \u2014 ' + colorToLabel(val));
1158
1288
  row.appendChild(swatch);
1159
1289
  row.appendChild(label);
1160
- frame.appendChild(row);
1290
+ column!.appendChild(row);
1161
1291
  }
1292
+
1293
+ frame.appendChild(grid);
1162
1294
  return frame;
1163
1295
  }
1164
1296
 
1165
- export function demoFrameRadii(): FrameNode {
1297
+ export function demoFrameRadii(): FrameNode | null {
1298
+ const r: Record<string, string> = filterConsumerOwned(_defaultThemeName, 'radius', getThemeGroup(_defaultThemeName, 'radius'));
1299
+ // Nothing the consumer wrote → hide the section entirely so the
1300
+ // panel doesn't display an empty "Radii" placeholder.
1301
+ if (Object.keys(r).length === 0) return null;
1302
+
1166
1303
  const frame = makeSectionFrame('Radii');
1167
1304
 
1168
1305
  const row = figma.createFrame();
@@ -1172,8 +1309,6 @@ export function demoFrameRadii(): FrameNode {
1172
1309
  row.itemSpacing = 16;
1173
1310
  row.fills = [];
1174
1311
  row.strokes = [];
1175
-
1176
- const r: Record<string, string> = getThemeGroup(_defaultThemeName, 'radius');
1177
1312
  for (const k of Object.keys(r)) {
1178
1313
  const cell = figma.createFrame();
1179
1314
  cell.layoutMode = 'VERTICAL';
@@ -1200,7 +1335,7 @@ export function demoFrameRadii(): FrameNode {
1200
1335
  }
1201
1336
 
1202
1337
  export function demoFrameFonts(theme: string): FrameNode | null {
1203
- const fonts: Record<string, string> = getThemeGroup(theme, 'font');
1338
+ const fonts: Record<string, string> = filterConsumerOwned(theme, 'font', getThemeGroup(theme, 'font'));
1204
1339
  const keys = Object.keys(fonts);
1205
1340
  if (keys.length === 0) return null;
1206
1341
 
@@ -1208,22 +1343,13 @@ export function demoFrameFonts(theme: string): FrameNode | null {
1208
1343
  frame.appendChild(makeSectionTitle(getThemeDisplayName(theme)));
1209
1344
 
1210
1345
  for (const key of keys) {
1211
- const family = (function () {
1212
- const raw = String(fonts[key] || '');
1213
- const parts = raw.split(',');
1214
- for (const part of parts) {
1215
- const trimmed = part.trim();
1216
- const varMatch = trimmed.match(/^var\(--font-([a-z0-9-]+)\)/i);
1217
- if (varMatch) {
1218
- return varMatch[1].split('-').map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
1219
- }
1220
- const lower = trimmed.toLowerCase().replace(/^["']|["']$/g, '');
1221
- if (lower === 'ui-sans-serif' || lower === 'system-ui' || lower === 'sans-serif' || lower === 'serif' || lower === 'monospace' || lower === 'ui-monospace') continue;
1222
- const cleaned = trimmed.replace(/^["']|["']$/g, '');
1223
- if (cleaned) return cleaned;
1224
- }
1225
- return 'Inter';
1226
- })();
1346
+ // Use the shared extractor so this preview agrees with the actual
1347
+ // rendering pipeline (`getThemeFontFamily` + render-time
1348
+ // `setRangeFontName`). The previous inline extractor only filtered
1349
+ // 6 keywords, missing `ui-serif`, `SFMono-Regular`, `Apple Color
1350
+ // Emoji`, `Menlo`, etc. — so the preview rendered "SFMono-Regular"
1351
+ // labels that Figma can't load (falling back to Inter visually).
1352
+ const family = extractFontName(fonts[key]) || 'Inter';
1227
1353
 
1228
1354
  const cell = figma.createFrame();
1229
1355
  cell.layoutMode = 'VERTICAL';
@@ -1242,7 +1368,7 @@ export function demoFrameFonts(theme: string): FrameNode | null {
1242
1368
  }
1243
1369
 
1244
1370
  export function demoFrameSpacing(): FrameNode | null {
1245
- const spacing: Record<string, string> = getThemeGroup(_defaultThemeName, 'spacing');
1371
+ const spacing: Record<string, string> = filterConsumerOwned(_defaultThemeName, 'spacing', getThemeGroup(_defaultThemeName, 'spacing'));
1246
1372
  const keys = Object.keys(spacing);
1247
1373
  if (keys.length === 0) return null;
1248
1374
 
@@ -1273,21 +1399,40 @@ export function demoFrameSpacing(): FrameNode | null {
1273
1399
  }
1274
1400
 
1275
1401
  export function demoFrameFontSizes(): FrameNode | null {
1276
- const sizes: Record<string, string> = getThemeGroup(_defaultThemeName, 'fontSize');
1402
+ const sizes: Record<string, string> = filterConsumerOwned(_defaultThemeName, 'fontSize', getThemeGroup(_defaultThemeName, 'fontSize'));
1277
1403
  const keys = Object.keys(sizes);
1278
1404
  if (keys.length === 0) return null;
1279
1405
 
1280
1406
  const frame = makeSectionFrame('Font sizes');
1281
1407
 
1408
+ // Inner wrapping row so large tokens (3xl, 9xl, …) lay out horizontally
1409
+ // and roll to a new line when they overflow, instead of stacking into a
1410
+ // tall single column. Label text is just the token key (`9xl`) rendered
1411
+ // at its actual size — the visual IS the size, no need for a "—128px
1412
+ // sample" annotation.
1413
+ const row = figma.createFrame();
1414
+ row.name = 'fontsize-row';
1415
+ row.layoutMode = 'HORIZONTAL';
1416
+ row.layoutWrap = 'WRAP';
1417
+ row.primaryAxisSizingMode = 'FIXED';
1418
+ row.counterAxisSizingMode = 'AUTO';
1419
+ row.resize(720, row.height);
1420
+ row.itemSpacing = 32;
1421
+ row.counterAxisSpacing = 16;
1422
+ row.counterAxisAlignItems = 'MIN';
1423
+ row.fills = [];
1424
+ row.strokes = [];
1425
+
1282
1426
  for (const k of keys) {
1283
1427
  const px = pxFromSizeToken(sizes[k]);
1284
- frame.appendChild(makeBodyText(k + ' \u2014 ' + Math.round(px) + 'px sample', { size: px }));
1428
+ row.appendChild(makeBodyText(k, { size: px }));
1285
1429
  }
1430
+ frame.appendChild(row);
1286
1431
  return frame;
1287
1432
  }
1288
1433
 
1289
1434
  export function demoFrameBreakpoints(): FrameNode | null {
1290
- const breakpoints: Record<string, string> = getThemeGroup(_defaultThemeName, 'breakpoint');
1435
+ const breakpoints: Record<string, string> = filterConsumerOwned(_defaultThemeName, 'breakpoint', getThemeGroup(_defaultThemeName, 'breakpoint'));
1291
1436
  const keys = Object.keys(breakpoints);
1292
1437
  if (keys.length === 0) return null;
1293
1438
 
@@ -1334,19 +1479,30 @@ export function demoFrameBreakpoints(): FrameNode | null {
1334
1479
  const SHADOW_SIZE_ORDER = ['2xs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl'];
1335
1480
 
1336
1481
  export function demoFrameShadows(theme: string): FrameNode | null {
1337
- const shadows: Record<string, string> = getThemeGroup(theme, 'shadow');
1482
+ const shadows: Record<string, string> = filterConsumerOwned(theme, 'shadow', getThemeGroup(theme, 'shadow'));
1338
1483
  const ordered = SHADOW_SIZE_ORDER.filter((k) => shadows[k]);
1339
1484
  if (ordered.length === 0) return null;
1340
1485
 
1341
1486
  const frame = makeSectionFrame(theme.toUpperCase() + ' Shadows');
1342
- // Larger gap so big shadows (shadow-xl, shadow-2xl) don't visually overlap
1343
- // the next swatch — the default itemSpacing is too tight for shadow-2xl
1344
- // which extends ~38px below its rect.
1345
- frame.itemSpacing = 56;
1346
1487
  // Keep shadows from being clipped by the wrapper's bounds.
1347
1488
  frame.clipsContent = false;
1348
1489
  frame.appendChild(makeSectionTitle(getThemeDisplayName(theme)));
1349
1490
 
1491
+ // Inner horizontal row so swatches read left-to-right under the title.
1492
+ // The outer section frame stays VERTICAL so the title sits above. Larger
1493
+ // itemSpacing here so big shadows (shadow-xl, shadow-2xl) don't visually
1494
+ // overlap their neighbour — defaults are too tight for shadow-2xl which
1495
+ // extends ~38px past its rect.
1496
+ const row = figma.createFrame();
1497
+ row.name = 'shadow-row';
1498
+ row.layoutMode = 'HORIZONTAL';
1499
+ row.primaryAxisSizingMode = 'AUTO';
1500
+ row.counterAxisSizingMode = 'AUTO';
1501
+ row.itemSpacing = 56;
1502
+ row.fills = [];
1503
+ row.strokes = [];
1504
+ row.clipsContent = false;
1505
+
1350
1506
  for (const k of ordered) {
1351
1507
  const cell = figma.createFrame();
1352
1508
  cell.layoutMode = 'VERTICAL';
@@ -1381,7 +1537,8 @@ export function demoFrameShadows(theme: string): FrameNode | null {
1381
1537
 
1382
1538
  cell.appendChild(swatch);
1383
1539
  cell.appendChild(makeBodyText(k, { size: 12 }));
1384
- frame.appendChild(cell);
1540
+ row.appendChild(cell);
1385
1541
  }
1542
+ frame.appendChild(row);
1386
1543
  return frame;
1387
1544
  }
@@ -59,6 +59,14 @@ export async function GET(request: Request): Promise<NextResponse> {
59
59
  if (dtcgTokenPath) args.push('--dtcg-token-path', dtcgTokenPath);
60
60
 
61
61
  const result = spawnSync(TSX, args, { cwd: CWD, encoding: 'utf-8' });
62
+ // Forward captured subprocess streams to the dev server's terminal.
63
+ // `spawnSync` with `encoding` captures stdout/stderr into the result
64
+ // object instead of inheriting them, so any `console.log` /
65
+ // `console.error` inside the scanner would otherwise be invisible.
66
+ // Reprinting here means scanner diagnostics surface in the same place
67
+ // as Next.js logs — no per-debug-session instrumentation needed.
68
+ if (result.stdout) process.stdout.write(result.stdout);
69
+ if (result.stderr) process.stderr.write(result.stderr);
62
70
  if (result.status !== 0) {
63
71
  throw new Error(result.stderr || 'Scanner exited with non-zero status');
64
72
  }
package/ui.html CHANGED
@@ -91,6 +91,48 @@
91
91
  letter-spacing: 0.5px;
92
92
  }
93
93
 
94
+ /* Advanced disclosure — `<details>`-based collapsible section.
95
+ Keeps DTCG-mode + sync knobs out of sight for CSS-first users
96
+ (the majority) but one click away when needed. */
97
+ .advanced-details {
98
+ margin: 0;
99
+ }
100
+ .advanced-details > summary {
101
+ cursor: pointer;
102
+ list-style: none;
103
+ font-size: 11px;
104
+ font-weight: 600;
105
+ color: #333;
106
+ text-transform: uppercase;
107
+ letter-spacing: 0.5px;
108
+ padding: 0;
109
+ margin-bottom: 12px;
110
+ user-select: none;
111
+ display: flex;
112
+ align-items: center;
113
+ gap: 6px;
114
+ }
115
+ .advanced-details > summary::-webkit-details-marker { display: none; }
116
+ .advanced-details > summary::before {
117
+ content: '▸';
118
+ font-size: 10px;
119
+ color: #666;
120
+ transition: transform 0.15s ease;
121
+ display: inline-block;
122
+ width: 8px;
123
+ text-align: center;
124
+ }
125
+ .advanced-details[open] > summary::before {
126
+ transform: rotate(90deg);
127
+ }
128
+ .advanced-details > summary > .summary-hint {
129
+ font-weight: 400;
130
+ font-size: 10px;
131
+ color: #888;
132
+ text-transform: none;
133
+ letter-spacing: 0;
134
+ }
135
+
94
136
  .repo-display {
95
137
  font-size: 11px;
96
138
  color: #666;
@@ -439,48 +481,54 @@
439
481
  <input type="text" id="settingsBranch" placeholder="main" value="main">
440
482
  </div>
441
483
 
442
- <div class="field">
443
- <label>Token Source Mode</label>
444
- <select id="settingsTokenSourceMode">
445
- <option value="css">css (auto-discover CSS, fallback to DTCG)</option>
446
- <option value="dtcg">dtcg (force DTCG file only)</option>
447
- </select>
448
- <p class="hint">CSS mode auto-discovers your globals.css and falls back to DTCG if not found. Use DTCG to force the legacy token file.</p>
449
- </div>
484
+ <div class="divider"></div>
450
485
 
451
- <div class="field" id="tokenPathField">
452
- <label>DTCG Token Path</label>
453
- <input type="text" id="settingsTokenPath" placeholder="design-tokens/tokens.dtcg.json" value="design-tokens/tokens.dtcg.json">
454
- <p class="hint">Path to the DTCG token file (used in dtcg mode or as fallback in auto mode)</p>
455
- </div>
486
+ <details class="advanced-details" id="advancedTokenDetails">
487
+ <summary>Advanced Token Sync <span class="summary-hint">(DTCG, push policy)</span></summary>
456
488
 
457
- <div class="field" id="cssTokenPathField">
458
- <label>CSS Token Path (optional)</label>
459
- <input type="text" id="settingsCssTokenPath" placeholder="src/app/tokens.css">
460
- <p class="hint">Overrides CSS auto-discovery when set.</p>
461
- </div>
489
+ <div class="field">
490
+ <label>Token Source Mode</label>
491
+ <select id="settingsTokenSourceMode">
492
+ <option value="css">css (recommended auto-discover globals.css)</option>
493
+ <option value="dtcg">dtcg (force DTCG file — Tokens Studio / Style Dictionary)</option>
494
+ </select>
495
+ <p class="hint">Most projects use CSS-first tokens (custom properties in <code>globals.css</code> or imported tokens.css). Pick <code>dtcg</code> only if your source of truth is a <code>tokens.dtcg.json</code> file.</p>
496
+ </div>
462
497
 
463
- <div class="field" id="syncDtcgOnPushField">
464
- <div style="display:flex;align-items:center;gap:8px;">
465
- <input type="checkbox" id="settingsSyncDtcgOnPush" style="width:auto;flex-shrink:0;">
466
- <label for="settingsSyncDtcgOnPush" style="display:inline;margin:0;cursor:pointer;">Also update DTCG on Push to Code</label>
498
+ <div class="field" id="cssTokenPathField">
499
+ <label>CSS Token Path (optional)</label>
500
+ <input type="text" id="settingsCssTokenPath" placeholder="src/app/tokens.css">
501
+ <p class="hint">Overrides CSS auto-discovery when set. Leave blank unless your tokens live outside <code>globals.css</code> and aren't imported from it.</p>
467
502
  </div>
468
- <p class="hint">When enabled, the plugin also commits <code>tokens.dtcg.json</code> as a generated artifact.</p>
469
- </div>
470
503
 
471
- <div class="field">
472
- <div style="display:flex;align-items:center;gap:8px;">
473
- <input type="checkbox" id="settingsAllowNewTokensFromFigma" style="width:auto;flex-shrink:0;">
474
- <label for="settingsAllowNewTokensFromFigma" style="display:inline;margin:0;cursor:pointer;">Allow New Tokens from Figma</label>
504
+ <div class="field" id="tokenPathField">
505
+ <label>DTCG Token Path</label>
506
+ <input type="text" id="settingsTokenPath" placeholder="design-tokens/tokens.dtcg.json">
507
+ <p class="hint">Path to the DTCG token file (used in dtcg mode or as fallback in css mode when no CSS file is found). Leave blank for the default <code>design-tokens/tokens.dtcg.json</code>.</p>
475
508
  </div>
476
- <p class="hint">Disabled by default. When enabled, new token keys can be added to code on push.</p>
477
- </div>
478
509
 
479
- <div class="field" id="newTokenPrefixesField" style="display:none;">
480
- <label>New Token Prefixes (optional)</label>
481
- <input type="text" id="settingsNewTokenPrefixes" placeholder="chart-, brand-">
482
- <p class="hint">Comma-separated. Only tokens with these prefixes can be added. Leave empty to allow all new tokens.</p>
483
- </div>
510
+ <div class="field" id="syncDtcgOnPushField">
511
+ <div style="display:flex;align-items:center;gap:8px;">
512
+ <input type="checkbox" id="settingsSyncDtcgOnPush" style="width:auto;flex-shrink:0;">
513
+ <label for="settingsSyncDtcgOnPush" style="display:inline;margin:0;cursor:pointer;">Also update DTCG on Push to Code</label>
514
+ </div>
515
+ <p class="hint">When enabled, the plugin also commits <code>tokens.dtcg.json</code> as a generated artifact alongside the CSS file.</p>
516
+ </div>
517
+
518
+ <div class="field">
519
+ <div style="display:flex;align-items:center;gap:8px;">
520
+ <input type="checkbox" id="settingsAllowNewTokensFromFigma" style="width:auto;flex-shrink:0;">
521
+ <label for="settingsAllowNewTokensFromFigma" style="display:inline;margin:0;cursor:pointer;">Allow New Tokens from Figma</label>
522
+ </div>
523
+ <p class="hint">Disabled by default. When enabled, new token keys can be added to code on push.</p>
524
+ </div>
525
+
526
+ <div class="field" id="newTokenPrefixesField" style="display:none;">
527
+ <label>New Token Prefixes (optional)</label>
528
+ <input type="text" id="settingsNewTokenPrefixes" placeholder="chart-, brand-">
529
+ <p class="hint">Comma-separated. Only tokens with these prefixes can be added. Leave empty to allow all new tokens.</p>
530
+ </div>
531
+ </details>
484
532
 
485
533
  <div class="divider"></div>
486
534
 
@@ -497,6 +545,16 @@
497
545
 
498
546
  <div class="divider"></div>
499
547
 
548
+ <div class="section-title">Dev Server (Optional)</div>
549
+
550
+ <div class="field">
551
+ <label>Dev Server Port</label>
552
+ <input type="text" id="settingsDevServerPort" placeholder="e.g., 3000" inputmode="numeric" maxlength="5">
553
+ <p class="hint">Pin the scanner to one localhost port. Leave blank to auto-discover across 3000 / 4000 / 5173. Useful when you're running multiple local projects on different ports.</p>
554
+ </div>
555
+
556
+ <div class="divider"></div>
557
+
500
558
  <div class="section-title">Display (Optional)</div>
501
559
 
502
560
  <div class="field">
@@ -769,8 +827,14 @@
769
827
  var newTokenPrefixesField = document.getElementById('newTokenPrefixesField');
770
828
  var settingsToken = document.getElementById('settingsToken');
771
829
  var settingsProjectName = document.getElementById('settingsProjectName');
830
+ var settingsDevServerPort = document.getElementById('settingsDevServerPort');
772
831
  var settingsLicenseKey = document.getElementById('settingsLicenseKey');
773
832
 
833
+ // Cached configured dev-server port — empty string means "auto-discover".
834
+ // Updated on `config-loaded` and at save time so the port override takes
835
+ // effect for subsequent fetches without requiring a plugin reload.
836
+ var configuredDevServerPort = '';
837
+
774
838
  // Current detected changes
775
839
  var detectedChanges = { tokens: true, components: [] };
776
840
  var lastTokenSourceInfo = null;
@@ -1092,17 +1156,35 @@
1092
1156
 
1093
1157
  // Dev server fetch - runs in the UI iframe which has network access
1094
1158
  // (code.js sandbox cannot make fetch calls)
1095
- var DEV_SERVER_ENDPOINTS = [
1096
- 'http://localhost:3000/api/inkbridge/scan-components',
1097
- 'http://localhost:4000/api/inkbridge/scan-components',
1098
- 'http://localhost:5173/api/inkbridge/scan-components',
1099
- ];
1159
+ var DEFAULT_DEV_SERVER_PORTS = [3000, 4000, 5173];
1160
+
1161
+ // Resolve the dev-server ports to try. A configured `devServerPort`
1162
+ // (validated to 1-65535 by the config normaliser) collapses the
1163
+ // sweep to that single port — useful when the user has multiple
1164
+ // local projects on different ports.
1165
+ function getDevServerPorts() {
1166
+ var raw = (configuredDevServerPort || '').trim();
1167
+ if (!raw) return DEFAULT_DEV_SERVER_PORTS;
1168
+ var n = parseInt(raw, 10);
1169
+ if (!isFinite(n) || n < 1 || n > 65535) return DEFAULT_DEV_SERVER_PORTS;
1170
+ return [n];
1171
+ }
1172
+
1173
+ function getScanComponentsEndpoints() {
1174
+ var ports = getDevServerPorts();
1175
+ var out = [];
1176
+ for (var i = 0; i < ports.length; i++) {
1177
+ out.push('http://localhost:' + ports[i] + '/api/inkbridge/scan-components');
1178
+ }
1179
+ return out;
1180
+ }
1100
1181
 
1101
1182
  var DEV_SERVER_TIMEOUT_MS = 15000;
1102
1183
 
1103
1184
  async function fetchComponentDefs() {
1104
- for (var i = 0; i < DEV_SERVER_ENDPOINTS.length; i++) {
1105
- var url = DEV_SERVER_ENDPOINTS[i];
1185
+ var endpoints = getScanComponentsEndpoints();
1186
+ for (var i = 0; i < endpoints.length; i++) {
1187
+ var url = endpoints[i];
1106
1188
  try {
1107
1189
  var controller = new AbortController();
1108
1190
  var timeoutId = setTimeout(function() { controller.abort(); }, DEV_SERVER_TIMEOUT_MS);
@@ -1255,7 +1337,7 @@
1255
1337
  // UI-side license validation (code.js sandbox has no network)
1256
1338
  if (msg.type === 'validate-license') {
1257
1339
  var licenseResult = { tier: 'free', valid: false };
1258
- var validatePorts = ['3000', '4000', '5173'];
1340
+ var validatePorts = getDevServerPorts();
1259
1341
  for (var vp = 0; vp < validatePorts.length; vp++) {
1260
1342
  try {
1261
1343
  var vUrl = 'http://localhost:' + validatePorts[vp] + '/api/plugin/validate?key=' + encodeURIComponent(msg.key);
@@ -1426,10 +1508,25 @@
1426
1508
  settingsNewTokenPrefixes.value = Array.isArray(config.newTokenPrefixes) ? config.newTokenPrefixes.join(', ') : (typeof config.newTokenPrefixes === 'string' ? config.newTokenPrefixes : '');
1427
1509
  newTokenPrefixesField.style.display = config.allowNewTokensFromFigma === true ? 'block' : 'none';
1428
1510
  settingsProjectName.value = config.projectName || '';
1511
+ settingsDevServerPort.value = config.devServerPort || '';
1512
+ configuredDevServerPort = (config.devServerPort || '').trim();
1429
1513
  configuredTokenSourceMode = tokenSourceMode;
1430
1514
  applyTokenSourceModeVisibility(tokenSourceMode);
1431
1515
  setTokenSourceInfo(lastTokenSourceInfo);
1432
1516
 
1517
+ // Auto-expand the Advanced disclosure when the consumer has any
1518
+ // non-default token-sync setting. Default state (css mode, no
1519
+ // overrides, no DTCG sync, default new-token policy) stays
1520
+ // collapsed — clean form for the CSS-first majority.
1521
+ var advancedHasNonDefault =
1522
+ tokenSourceMode !== 'css'
1523
+ || (config.tokenPath && config.tokenPath !== 'design-tokens/tokens.dtcg.json')
1524
+ || (config.cssTokenPath && config.cssTokenPath.length > 0)
1525
+ || config.syncDtcgOnPush === true
1526
+ || config.allowNewTokensFromFigma === true;
1527
+ var advancedTokenDetails = document.getElementById('advancedTokenDetails');
1528
+ if (advancedTokenDetails) advancedTokenDetails.open = advancedHasNonDefault;
1529
+
1433
1530
  // Update push view repo info
1434
1531
  if (config.owner && config.repo) {
1435
1532
  repoLabel.textContent = config.owner + '/' + config.repo;
@@ -1650,10 +1747,14 @@
1650
1747
  .map(function(v) { return v.trim(); })
1651
1748
  .filter(function(v) { return v.length > 0; }),
1652
1749
  projectName: settingsProjectName.value.trim(),
1750
+ devServerPort: settingsDevServerPort.value.trim(),
1653
1751
  token: settingsToken.value.trim() || null,
1654
1752
  licenseKey: settingsLicenseKey.value.trim() || null
1655
1753
  }
1656
1754
  }, '*');
1755
+ // Keep the in-page port cache in sync so subsequent dev-server
1756
+ // fetches respect the new value without waiting for a reload.
1757
+ configuredDevServerPort = settingsDevServerPort.value.trim();
1657
1758
  };
1658
1759
 
1659
1760
  settingsTokenSourceMode.onchange = function() {