riazmo Claude Opus 4.6 commited on
Commit
13d9428
·
1 Parent(s): da7654a

feat: visual spec v3 — separate frames, AA badges, MD best practices

Browse files

Complete rewrite of visual spec following Material Design / Carbon patterns:

Separate Figma frames for each section:
- Title frame with font name
- Colors frame with category groups + AA contrast badges
- Typography Desktop frame (table layout: token | sample | specs)
- Typography Mobile frame (same layout)
- Spacing frame (desktop/mobile horizontal bars, Carbon-style)
- Border Radius frame
- Shadows frame (light gray background, 140x140 white cards)

Color improvements:
- 160x136px cards (64px swatch + 72px metadata area)
- WCAG AA contrast check on every color (ratio, pass/fail, best-on)
- Green "AA Pass" or red "AA Fail" badges with contrast ratio
- Full token name path visible (not just last segment)
- Grouped: brand → text → bg → border → feedback → palette hues

Typography improvements:
- 3-column table layout: Token Name | Sample Text | Specifications
- Sample text in actual extracted font (e.g. Open Sans)
- Pangram text per tier (display, heading, body, caption, overline)
- Prominent specs: Size, Weight, Line Height, Font — each on own line
- Column headers and row separators for readability

Spacing improvements:
- Horizontal bars (Carbon pattern) instead of squares
- Bar width proportional to value (3x scale, capped at 400px)
- Token name + bar + value label in clean rows
- Desktop and Mobile side by side

Shadow improvements:
- Light gray frame background (#F5F5F5) for shadow visibility
- 140x140px white cards with actual drop shadow applied
- Specs printed on each card (blur, y-offset, spread)

Also: 5-level shadow interpolation restored in app.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

output_json/figma-plugin-extracted/figma-design-token-creator 5/src/code.js CHANGED
@@ -40,6 +40,31 @@ function hexToRgb(hex) {
40
  } : { r: 0, g: 0, b: 0 };
41
  }
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  // Helper to convert font weight to Figma font style name
44
  function getFontStyleFromWeight(weight) {
45
  var weightStr = String(weight).toLowerCase();
@@ -782,526 +807,449 @@ figma.ui.onmessage = async function(msg) {
782
  }
783
 
784
  // ==========================================
785
- // VISUAL SPEC GENERATOR v2
786
- // Creates a professional visual reference page showing all tokens
 
787
  // ==========================================
788
  if (msg.type === 'create-visual-spec') {
789
  try {
790
  var rawTokens = msg.tokens;
791
-
792
- // Validate tokens exist
793
  if (!rawTokens) {
794
- figma.ui.postMessage({
795
- type: 'error',
796
- message: 'No tokens provided. Please load a JSON file first.'
797
- });
798
  return;
799
  }
800
 
801
- console.log('Creating visual spec with tokens:', Object.keys(rawTokens));
802
  var tokens = normalizeTokens(rawTokens);
803
- console.log('Normalized tokens - colors:', tokens.colors.length, 'typography:', tokens.typography.length);
804
-
805
- // Use current page (Figma Starter plan has 3-page limit)
806
  var specPage = figma.currentPage;
807
  specPage.name = '🎨 Design System Spec';
808
-
809
- // Clear existing children
810
- while (specPage.children.length > 0) {
811
- specPage.children[0].remove();
812
- }
813
-
814
- // ── Layout constants ──
815
- var PAGE_PADDING = 60;
816
- var SECTION_GAP = 100;
817
  var ITEM_GAP = 16;
818
- var xOffset = PAGE_PADDING;
819
- var yOffset = PAGE_PADDING;
820
- var PAGE_WIDTH = 1200;
 
821
 
822
  // ── Load fonts ──
823
- // 1. Inter Regular/Bold for labels (always available)
824
- var headingStyle = 'Regular';
825
  var labelFont = { family: 'Inter', style: 'Regular' };
 
826
  await figma.loadFontAsync(labelFont);
827
- try {
828
- await figma.loadFontAsync({ family: 'Inter', style: 'Bold' });
829
- headingStyle = 'Bold';
830
- } catch (e) {
831
- console.warn('Inter Bold not available');
832
- }
833
- var headingFont = { family: 'Inter', style: headingStyle };
834
 
835
- // 2. Try to load the extracted font for sample text
836
  var sampleFontFamily = 'Inter';
837
- var sampleFontStyle = 'Regular';
838
- var sampleFontBoldStyle = headingStyle;
839
- // Find the primary font from typography tokens
840
  for (var fi = 0; fi < tokens.typography.length; fi++) {
841
  var ff = (tokens.typography[fi].value.fontFamily || '').split(',')[0].trim();
842
- if (ff && ff !== 'Inter' && ff !== 'sans-serif') {
843
- sampleFontFamily = ff;
844
- break;
845
- }
846
  }
847
  if (sampleFontFamily !== 'Inter') {
848
  try {
849
  await figma.loadFontAsync({ family: sampleFontFamily, style: 'Regular' });
850
- sampleFontStyle = 'Regular';
851
- // Try Bold
852
- try {
853
- await figma.loadFontAsync({ family: sampleFontFamily, style: 'Bold' });
854
- sampleFontBoldStyle = 'Bold';
855
- } catch (e2) {
856
- sampleFontBoldStyle = 'Regular';
857
- }
858
- } catch (e) {
859
- console.warn(sampleFontFamily + ' not available, falling back to Inter');
860
- sampleFontFamily = 'Inter';
861
- sampleFontStyle = 'Regular';
862
- sampleFontBoldStyle = headingStyle;
863
- }
864
  }
865
- var sampleFont = { family: sampleFontFamily, style: sampleFontStyle };
866
- var sampleFontBold = { family: sampleFontFamily, style: sampleFontBoldStyle };
867
-
868
- // ── Helper: add section title ──
869
- function addSectionTitle(text) {
870
- var title = figma.createText();
871
- title.fontName = headingFont;
872
- title.fontSize = 28;
873
- title.characters = text;
874
- title.fills = [{ type: 'SOLID', color: { r: 0.1, g: 0.1, b: 0.1 } }];
875
- title.x = xOffset;
876
- title.y = yOffset;
877
- specPage.appendChild(title);
878
- yOffset += 50;
879
- // Divider line
880
- var line = figma.createRectangle();
881
- line.resize(PAGE_WIDTH - PAGE_PADDING * 2, 1);
882
- line.x = xOffset;
883
- line.y = yOffset;
884
- line.fills = [{ type: 'SOLID', color: { r: 0.85, g: 0.85, b: 0.85 } }];
885
- specPage.appendChild(line);
886
- yOffset += 24;
887
  }
888
 
889
- // ── Helper: add sub-heading ──
890
- function addSubHeading(text) {
891
- var sub = figma.createText();
892
- sub.fontName = headingFont;
893
- sub.fontSize = 16;
894
- sub.characters = text;
895
- sub.fills = [{ type: 'SOLID', color: { r: 0.35, g: 0.35, b: 0.35 } }];
896
- sub.x = xOffset;
897
- sub.y = yOffset;
898
- specPage.appendChild(sub);
899
- yOffset += 32;
900
  }
901
 
902
- // ══════════════════════════════════════════
903
- // 1. WHITE BACKGROUND
904
- // ══════════════════════════════════════════
905
- // We'll place it at the end once we know the total height.
906
- // For now, track startY.
907
- var bgStartY = 0;
908
-
909
- // ── Page title ──
910
- var pageTitle = figma.createText();
911
- pageTitle.fontName = headingFont;
912
- pageTitle.fontSize = 36;
913
- pageTitle.characters = 'Design System Specification';
914
- pageTitle.fills = [{ type: 'SOLID', color: { r: 0.1, g: 0.1, b: 0.1 } }];
915
- pageTitle.x = xOffset;
916
- pageTitle.y = yOffset;
917
- specPage.appendChild(pageTitle);
918
- yOffset += 60;
919
-
920
- // ══════════════════════════════════════════
921
- // 2. COLORS — grouped by category
922
- // ══════════════════════════════════════════
923
- if (tokens.colors.length > 0) {
924
- addSectionTitle('COLORS');
925
 
926
- // Group colors by top-level category from name path
927
- // e.g. "brand/primary/DEFAULT" category "brand"
928
- // "blue/50" → category "blue" (palette)
 
 
929
  var colorGroups = {};
930
  var groupOrder = [];
931
  for (var ci = 0; ci < tokens.colors.length; ci++) {
932
- var cToken = tokens.colors[ci];
933
- var parts = cToken.name.split('/');
934
- var category = parts[0] || 'other';
935
- if (!colorGroups[category]) {
936
- colorGroups[category] = [];
937
- groupOrder.push(category);
938
- }
939
- colorGroups[category].push(cToken);
940
  }
941
-
942
- // Semantic categories first, then palette
943
- var semanticOrder = ['brand', 'text', 'bg', 'background', 'border', 'feedback'];
944
  var sortedGroups = [];
945
- for (var so = 0; so < semanticOrder.length; so++) {
946
- if (colorGroups[semanticOrder[so]]) {
947
- sortedGroups.push(semanticOrder[so]);
948
- }
949
- }
950
- for (var go = 0; go < groupOrder.length; go++) {
951
- if (sortedGroups.indexOf(groupOrder[go]) === -1) {
952
- sortedGroups.push(groupOrder[go]);
953
- }
954
  }
 
955
 
956
- var swatchSize = 56;
957
- var swatchGap = 12;
 
 
 
 
958
 
959
  for (var gi = 0; gi < sortedGroups.length; gi++) {
960
- var groupName = sortedGroups[gi];
961
- var groupColors = colorGroups[groupName];
962
 
963
- // Category label
964
- addSubHeading(groupName.charAt(0).toUpperCase() + groupName.slice(1));
 
965
 
966
- var colorX = xOffset;
967
- for (var ci2 = 0; ci2 < groupColors.length; ci2++) {
968
- var ct = groupColors[ci2];
 
 
 
969
 
970
- // Swatch
971
  var swatch = figma.createRectangle();
972
- swatch.resize(swatchSize, swatchSize);
973
- swatch.x = colorX;
974
- swatch.y = yOffset;
975
- swatch.cornerRadius = 8;
976
- var rgb = hexToRgb(ct.value);
977
- swatch.fills = [{ type: 'SOLID', color: rgb }];
978
- swatch.strokes = [{ type: 'SOLID', color: { r: 0.88, g: 0.88, b: 0.88 } }];
979
- swatch.strokeWeight = 1;
980
- specPage.appendChild(swatch);
981
-
982
- // Token name (last segment, skip DEFAULT)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
983
  var nameParts = ct.name.split('/');
984
- var displayName = nameParts[nameParts.length - 1];
985
- if (displayName === 'DEFAULT') {
986
- displayName = nameParts.length > 1 ? nameParts[nameParts.length - 2] : displayName;
987
- }
988
- var cLabel = figma.createText();
989
- cLabel.fontName = labelFont;
990
- cLabel.fontSize = 9;
991
- cLabel.characters = displayName;
992
- cLabel.x = colorX;
993
- cLabel.y = yOffset + swatchSize + 4;
994
- specPage.appendChild(cLabel);
995
 
996
  // Hex value
997
- var hexLabel = figma.createText();
998
- hexLabel.fontName = labelFont;
999
- hexLabel.fontSize = 8;
1000
- hexLabel.characters = ct.value.toUpperCase();
1001
- hexLabel.fills = [{ type: 'SOLID', color: { r: 0.55, g: 0.55, b: 0.55 } }];
1002
- hexLabel.x = colorX;
1003
- hexLabel.y = yOffset + swatchSize + 16;
1004
- specPage.appendChild(hexLabel);
1005
-
1006
- colorX += swatchSize + swatchGap;
1007
  }
1008
 
1009
- yOffset += swatchSize + 36 + ITEM_GAP;
 
1010
  }
1011
-
1012
- yOffset += SECTION_GAP - ITEM_GAP;
1013
  }
1014
 
1015
- // ══════════════════════════════════════════
1016
- // 3. TYPOGRAPHY Desktop & Mobile side by side
1017
- // ══════════════════════════════════════════
1018
- if (tokens.typography.length > 0) {
1019
- addSectionTitle('TYPOGRAPHY');
1020
-
1021
- // Separate desktop and mobile tokens
1022
- var desktopTypo = [];
1023
- var mobileTypo = [];
1024
- for (var ti = 0; ti < tokens.typography.length; ti++) {
1025
- var tToken = tokens.typography[ti];
1026
- var tName = tToken.name.toLowerCase();
1027
- if (tName.indexOf('mobile') > -1) {
1028
- mobileTypo.push(tToken);
1029
- } else {
1030
- desktopTypo.push(tToken);
1031
- }
1032
  }
 
1033
 
1034
- // Pangram sample texts per tier
1035
- function getSampleText(tokenName) {
1036
- var n = tokenName.toLowerCase();
1037
- if (n.indexOf('display') > -1) return 'The quick brown fox jumps over the lazy dog';
1038
- if (n.indexOf('heading') > -1) return 'The quick brown fox jumps over';
1039
- if (n.indexOf('body') > -1) return 'The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs.';
1040
- if (n.indexOf('caption') > -1) return 'The quick brown fox jumps over the lazy dog';
1041
- if (n.indexOf('overline') > -1) return 'THE QUICK BROWN FOX';
1042
- return 'The quick brown fox jumps over the lazy dog';
1043
- }
1044
 
1045
- // Get display tier for font weight
1046
- function getTierFont(tokenName) {
1047
- var n = tokenName.toLowerCase();
1048
- if (n.indexOf('display') > -1 || n.indexOf('heading') > -1) return sampleFontBold;
1049
- return sampleFont;
1050
- }
1051
 
1052
- // Render a typography column
1053
- function renderTypoColumn(typoList, columnX, columnTitle) {
1054
- var localY = yOffset;
1055
- // Column title
1056
- var colTitle = figma.createText();
1057
- colTitle.fontName = headingFont;
1058
- colTitle.fontSize = 14;
1059
- colTitle.characters = columnTitle;
1060
- colTitle.fills = [{ type: 'SOLID', color: { r: 0.2, g: 0.5, b: 1 } }];
1061
- colTitle.x = columnX;
1062
- colTitle.y = localY;
1063
- specPage.appendChild(colTitle);
1064
- localY += 30;
1065
-
1066
- for (var tti = 0; tti < typoList.length; tti++) {
1067
- var tt = typoList[tti];
1068
- var val = tt.value;
1069
- var fFamily = (val.fontFamily || 'Inter').split(',')[0].trim();
1070
- var fSize = parseNumericValue(val.fontSize) || 16;
1071
- var fWeight = val.fontWeight || '400';
1072
- var fLineHeight = val.lineHeight || '1.5';
1073
- var displaySize = Math.min(fSize, 48);
1074
-
1075
- // Token name label (e.g. "heading.lg")
1076
- var tierParts = tt.name.split('/');
1077
- // Remove 'desktop'/'mobile' from display
1078
- var tierName = tierParts.filter(function(p) { return p !== 'desktop' && p !== 'mobile'; }).join('/');
1079
-
1080
- var nameLabel = figma.createText();
1081
- nameLabel.fontName = labelFont;
1082
- nameLabel.fontSize = 10;
1083
- nameLabel.characters = tierName;
1084
- nameLabel.fills = [{ type: 'SOLID', color: { r: 0.5, g: 0.5, b: 0.5 } }];
1085
- nameLabel.x = columnX;
1086
- nameLabel.y = localY;
1087
- specPage.appendChild(nameLabel);
1088
- localY += 16;
1089
-
1090
- // Sample text in actual font
1091
- var sample = figma.createText();
1092
- sample.fontName = getTierFont(tt.name);
1093
- sample.fontSize = displaySize;
1094
- sample.characters = getSampleText(tt.name);
1095
- sample.x = columnX;
1096
- sample.y = localY;
1097
- // Constrain width so long text wraps
1098
- sample.resize(480, sample.height);
1099
- sample.textAutoResize = 'HEIGHT';
1100
- specPage.appendChild(sample);
1101
- localY += sample.height + 6;
1102
-
1103
- // Spec details font size, weight, line height
1104
- var specLine = figma.createText();
1105
- specLine.fontName = labelFont;
1106
- specLine.fontSize = 10;
1107
- specLine.characters = fFamily + ' · ' + fSize + 'px · wt ' + fWeight + ' · LH ' + fLineHeight;
1108
- specLine.fills = [{ type: 'SOLID', color: { r: 0.6, g: 0.6, b: 0.6 } }];
1109
- specLine.x = columnX;
1110
- specLine.y = localY;
1111
- specPage.appendChild(specLine);
1112
- localY += 28;
1113
- }
1114
- return localY;
 
 
 
 
 
 
 
 
 
 
 
 
1115
  }
1116
 
1117
- var colWidth = 520;
1118
- var desktopEndY = renderTypoColumn(desktopTypo, xOffset, '🖥 Desktop');
1119
- var mobileEndY = renderTypoColumn(mobileTypo, xOffset + colWidth, '📱 Mobile');
1120
-
1121
- yOffset = Math.max(desktopEndY, mobileEndY) + SECTION_GAP;
1122
  }
1123
 
1124
- // ══════════════════════════════════════════
1125
- // 4. SPACING Desktop & Mobile with square blocks
1126
- // ══════════════════════════════════════════
1127
- if (tokens.spacing.length > 0) {
1128
- addSectionTitle('SPACING');
1129
 
1130
- // Separate desktop and mobile
1131
- var desktopSpacing = [];
1132
- var mobileSpacing = [];
 
 
 
1133
  for (var spi = 0; spi < tokens.spacing.length; spi++) {
1134
- var spToken = tokens.spacing[spi];
1135
- var spName = spToken.name.toLowerCase();
1136
- if (spName.indexOf('mobile') > -1) {
1137
- mobileSpacing.push(spToken);
1138
  } else {
1139
- desktopSpacing.push(spToken);
1140
  }
1141
  }
1142
 
1143
- function renderSpacingColumn(spacingList, columnX, columnTitle) {
1144
- var localY = yOffset;
1145
- var colTitle = figma.createText();
1146
- colTitle.fontName = headingFont;
1147
- colTitle.fontSize = 14;
1148
- colTitle.characters = columnTitle;
1149
- colTitle.fills = [{ type: 'SOLID', color: { r: 0.2, g: 0.5, b: 1 } }];
1150
- colTitle.x = columnX;
1151
- colTitle.y = localY;
1152
- specPage.appendChild(colTitle);
1153
- localY += 30;
1154
-
1155
- var spX = columnX;
1156
- var maxH = 0;
1157
- for (var ssi = 0; ssi < Math.min(spacingList.length, 10); ssi++) {
1158
- var sp = spacingList[ssi];
1159
  var spVal = parseNumericValue(sp.value);
1160
- var blockSize = Math.max(spVal, 8); // Minimum visible size
1161
- var displayBlock = Math.min(blockSize, 80); // Cap for display
1162
-
1163
- // Blue square
1164
- var sq = figma.createRectangle();
1165
- sq.resize(displayBlock, displayBlock);
1166
- sq.x = spX;
1167
- sq.y = localY;
1168
- sq.cornerRadius = 4;
1169
- sq.fills = [{ type: 'SOLID', color: { r: 0.2, g: 0.55, b: 1 } }];
1170
- specPage.appendChild(sq);
1171
-
1172
- // Label below
1173
  var spParts = sp.name.split('/');
1174
  var spDisplayName = spParts.filter(function(p) { return p !== 'desktop' && p !== 'mobile'; }).join('/');
1175
- var sqLabel = figma.createText();
1176
- sqLabel.fontName = labelFont;
1177
- sqLabel.fontSize = 9;
1178
- sqLabel.characters = spDisplayName + '\n' + sp.value;
1179
- sqLabel.textAlignHorizontal = 'CENTER';
1180
- sqLabel.x = spX;
1181
- sqLabel.y = localY + displayBlock + 6;
1182
- specPage.appendChild(sqLabel);
1183
-
1184
- if (displayBlock > maxH) maxH = displayBlock;
1185
- spX += displayBlock + 20;
 
 
 
 
1186
  }
1187
- return localY + maxH + 40;
1188
  }
1189
 
1190
- var dsEndY = renderSpacingColumn(desktopSpacing, xOffset, '🖥 Desktop');
1191
- var msEndY = renderSpacingColumn(mobileSpacing, xOffset + colWidth, '📱 Mobile');
1192
 
1193
- yOffset = Math.max(dsEndY, msEndY) + SECTION_GAP;
1194
  }
1195
 
1196
- // ══════════════════════════════════════════
1197
- // 5. BORDER RADIUS
1198
- // ══════════════════════════════════════════
1199
  if (tokens.borderRadius.length > 0) {
1200
- addSectionTitle('BORDER RADIUS');
 
 
 
 
 
1201
 
1202
- var radiusX = xOffset;
1203
  for (var ri = 0; ri < tokens.borderRadius.length; ri++) {
1204
- var radiusToken = tokens.borderRadius[ri];
1205
- var radiusValue = parseNumericValue(radiusToken.value);
1206
 
1207
  var rect = figma.createRectangle();
1208
- rect.resize(56, 56);
1209
- rect.x = radiusX;
1210
- rect.y = yOffset;
1211
- rect.cornerRadius = Math.min(radiusValue, 28);
1212
- rect.fills = [{ type: 'SOLID', color: { r: 0.95, g: 0.95, b: 0.97 } }];
1213
- rect.strokes = [{ type: 'SOLID', color: { r: 0.8, g: 0.8, b: 0.85 } }];
1214
  rect.strokeWeight = 2;
1215
- specPage.appendChild(rect);
1216
-
1217
- var rLabel = figma.createText();
1218
- rLabel.fontName = labelFont;
1219
- rLabel.fontSize = 9;
1220
- rLabel.characters = radiusToken.name.split('/').pop() + '\n' + radiusToken.value;
1221
- rLabel.textAlignHorizontal = 'CENTER';
1222
- rLabel.x = radiusX;
1223
- rLabel.y = yOffset + 62;
1224
- specPage.appendChild(rLabel);
1225
-
1226
- radiusX += 80;
1227
- }
1228
 
1229
- yOffset += 110 + SECTION_GAP;
 
1230
  }
1231
 
1232
- // ══════════════════════════════════════════
1233
- // 6. SHADOWS
1234
- // ══════════════════════════════════════════
1235
  if (tokens.shadows.length > 0) {
1236
- addSectionTitle('SHADOWS');
1237
-
1238
- var shadowX = xOffset;
1239
- for (var shi = 0; shi < tokens.shadows.length; shi++) {
1240
- var shadowToken = tokens.shadows[shi];
1241
- var sv = shadowToken.value;
1242
 
1243
- var offsetXVal = parseNumericValue(sv.offsetX || sv.x || '0');
1244
- var offsetYVal = parseNumericValue(sv.offsetY || sv.y || '0');
1245
- var blurVal = parseNumericValue(sv.blur || '0');
1246
- var spreadVal = parseNumericValue(sv.spread || '0');
1247
- var shColor = parseColorToRGBA(sv.color || 'rgba(0,0,0,0.25)');
1248
 
1249
- // Shadow card
 
 
 
 
 
 
 
 
 
 
1250
  var card = figma.createRectangle();
1251
- card.resize(90, 90);
1252
- card.x = shadowX;
1253
- card.y = yOffset;
1254
  card.cornerRadius = 12;
1255
  card.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } }];
1256
  card.effects = [{
1257
  type: 'DROP_SHADOW',
1258
- color: { r: shColor.r, g: shColor.g, b: shColor.b, a: shColor.a },
1259
- offset: { x: offsetXVal, y: offsetYVal },
1260
- radius: blurVal,
1261
- spread: spreadVal,
1262
  visible: true,
1263
  blendMode: 'NORMAL'
1264
  }];
1265
- specPage.appendChild(card);
1266
-
1267
- // Name + specs
1268
- var shLabel = figma.createText();
1269
- shLabel.fontName = labelFont;
1270
- shLabel.fontSize = 9;
1271
- shLabel.characters = shadowToken.name.split('/').pop() + '\nblur: ' + blurVal + 'px y: ' + offsetYVal + 'px';
1272
- shLabel.fills = [{ type: 'SOLID', color: { r: 0.5, g: 0.5, b: 0.5 } }];
1273
- shLabel.x = shadowX;
1274
- shLabel.y = yOffset + 100;
1275
- specPage.appendChild(shLabel);
1276
-
1277
- shadowX += 130;
1278
- }
1279
 
1280
- yOffset += 140 + SECTION_GAP;
1281
- }
 
 
 
 
 
1282
 
1283
- // ══════════════════════════════════════════
1284
- // WHITE BACKGROUND — placed behind everything
1285
- // ══════════════════════════════════════════
1286
- var bgRect = figma.createRectangle();
1287
- bgRect.resize(PAGE_WIDTH, yOffset + PAGE_PADDING);
1288
- bgRect.x = 0;
1289
- bgRect.y = 0;
1290
- bgRect.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } }];
1291
- bgRect.locked = true;
1292
- bgRect.name = 'Background';
1293
- specPage.appendChild(bgRect);
1294
-
1295
- // Send background to back (behind all other elements)
1296
- var children = specPage.children;
1297
- if (children.length > 1) {
1298
- specPage.insertChild(0, bgRect);
1299
  }
1300
 
1301
- figma.ui.postMessage({
1302
- type: 'spec-complete',
1303
- message: 'Visual spec page created!'
1304
- });
1305
 
1306
  } catch (error) {
1307
  console.error('Error creating visual spec:', error);
 
40
  } : { r: 0, g: 0, b: 0 };
41
  }
42
 
43
+ // WCAG 2.1 contrast ratio calculation
44
+ function getRelativeLuminance(rgb) {
45
+ function sRGB(c) { return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }
46
+ return 0.2126 * sRGB(rgb.r) + 0.7152 * sRGB(rgb.g) + 0.0722 * sRGB(rgb.b);
47
+ }
48
+ function getContrastRatio(hex1, hex2) {
49
+ var l1 = getRelativeLuminance(hexToRgb(hex1));
50
+ var l2 = getRelativeLuminance(hexToRgb(hex2));
51
+ var lighter = Math.max(l1, l2);
52
+ var darker = Math.min(l1, l2);
53
+ return (lighter + 0.05) / (darker + 0.05);
54
+ }
55
+ function getAAResult(hex) {
56
+ var onWhite = getContrastRatio(hex, '#ffffff');
57
+ var onBlack = getContrastRatio(hex, '#000000');
58
+ var bestRatio = Math.max(onWhite, onBlack);
59
+ var bestOn = onWhite >= onBlack ? 'white' : 'black';
60
+ return {
61
+ ratio: Math.round(bestRatio * 10) / 10,
62
+ passAA: bestRatio >= 4.5,
63
+ passAAA: bestRatio >= 7,
64
+ bestOn: bestOn
65
+ };
66
+ }
67
+
68
  // Helper to convert font weight to Figma font style name
69
  function getFontStyleFromWeight(weight) {
70
  var weightStr = String(weight).toLowerCase();
 
807
  }
808
 
809
  // ==========================================
810
+ // ==========================================
811
+ // VISUAL SPEC GENERATOR v3 Material Design best-practices layout
812
+ // Each section in its own Figma FRAME. AA contrast badges. Prominent labels.
813
  // ==========================================
814
  if (msg.type === 'create-visual-spec') {
815
  try {
816
  var rawTokens = msg.tokens;
 
 
817
  if (!rawTokens) {
818
+ figma.ui.postMessage({ type: 'error', message: 'No tokens provided. Please load a JSON file first.' });
 
 
 
819
  return;
820
  }
821
 
 
822
  var tokens = normalizeTokens(rawTokens);
 
 
 
823
  var specPage = figma.currentPage;
824
  specPage.name = '🎨 Design System Spec';
825
+ while (specPage.children.length > 0) { specPage.children[0].remove(); }
826
+
827
+ // ── Constants (Material Design / Carbon inspired) ──
828
+ var FRAME_W = 1440;
829
+ var CONTENT_W = 1200;
830
+ var MARGIN = 120;
831
+ var SECTION_PAD = 48;
832
+ var SECTION_GAP = 40; // gap between frames on the page
 
833
  var ITEM_GAP = 16;
834
+ var SWATCH_W = 160;
835
+ var SWATCH_H_COLOR = 64;
836
+ var SWATCH_META_H = 72;
837
+ var SWATCH_GAP = 12;
838
 
839
  // ── Load fonts ──
 
 
840
  var labelFont = { family: 'Inter', style: 'Regular' };
841
+ var boldFont = { family: 'Inter', style: 'Regular' };
842
  await figma.loadFontAsync(labelFont);
843
+ try { await figma.loadFontAsync({ family: 'Inter', style: 'Bold' }); boldFont = { family: 'Inter', style: 'Bold' }; } catch (e) {}
 
 
 
 
 
 
844
 
845
+ // Try loading extracted font
846
  var sampleFontFamily = 'Inter';
847
+ var sampleFont = labelFont;
848
+ var sampleFontBold = boldFont;
 
849
  for (var fi = 0; fi < tokens.typography.length; fi++) {
850
  var ff = (tokens.typography[fi].value.fontFamily || '').split(',')[0].trim();
851
+ if (ff && ff !== 'Inter' && ff !== 'sans-serif' && ff !== 'serif') { sampleFontFamily = ff; break; }
 
 
 
852
  }
853
  if (sampleFontFamily !== 'Inter') {
854
  try {
855
  await figma.loadFontAsync({ family: sampleFontFamily, style: 'Regular' });
856
+ sampleFont = { family: sampleFontFamily, style: 'Regular' };
857
+ try { await figma.loadFontAsync({ family: sampleFontFamily, style: 'Bold' }); sampleFontBold = { family: sampleFontFamily, style: 'Bold' }; } catch (e2) { sampleFontBold = sampleFont; }
858
+ } catch (e) { sampleFontFamily = 'Inter'; sampleFont = labelFont; sampleFontBold = boldFont; }
 
 
 
 
 
 
 
 
 
 
 
859
  }
860
+
861
+ var pageY = 0; // tracks vertical position of next frame on the page
862
+
863
+ // ── Helper: create a section FRAME ──
864
+ function createSectionFrame(name, contentHeight) {
865
+ var frame = figma.createFrame();
866
+ frame.name = name;
867
+ frame.resize(FRAME_W, contentHeight);
868
+ frame.x = 0;
869
+ frame.y = pageY;
870
+ frame.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } }];
871
+ frame.clipsContent = false;
872
+ specPage.appendChild(frame);
873
+ pageY += contentHeight + SECTION_GAP;
874
+ return frame;
 
 
 
 
 
 
 
875
  }
876
 
877
+ // ── Helper: add text to a frame ──
878
+ function addText(frame, text, font, size, x, y, color) {
879
+ var t = figma.createText();
880
+ t.fontName = font;
881
+ t.fontSize = size;
882
+ t.characters = text;
883
+ t.fills = [{ type: 'SOLID', color: color || { r: 0.1, g: 0.1, b: 0.1 } }];
884
+ t.x = x; t.y = y;
885
+ frame.appendChild(t);
886
+ return t;
 
887
  }
888
 
889
+ var MUTED = { r: 0.5, g: 0.5, b: 0.5 };
890
+ var DARK = { r: 0.12, g: 0.12, b: 0.12 };
891
+ var BLUE = { r: 0.15, g: 0.45, b: 0.95 };
892
+
893
+ // ══════════════════════════════════════════════════════════
894
+ // TITLE FRAME
895
+ // ══════════════════════════════════════════════════════════
896
+ var titleFrame = createSectionFrame('Title', 120);
897
+ addText(titleFrame, 'Design System Specification', boldFont, 36, MARGIN, 32, DARK);
898
+ addText(titleFrame, 'Font: ' + sampleFontFamily, labelFont, 14, MARGIN, 80, MUTED);
 
 
 
 
 
 
 
 
 
 
 
 
 
899
 
900
+ // ══════════════════════════════════════════════════════════
901
+ // COLORS FRAME grouped by category with AA badges
902
+ // ══════════════════════════════════════════════════════════
903
+ if (tokens.colors.length > 0) {
904
+ // Group colors by category
905
  var colorGroups = {};
906
  var groupOrder = [];
907
  for (var ci = 0; ci < tokens.colors.length; ci++) {
908
+ var cat = tokens.colors[ci].name.split('/')[0] || 'other';
909
+ if (!colorGroups[cat]) { colorGroups[cat] = []; groupOrder.push(cat); }
910
+ colorGroups[cat].push(tokens.colors[ci]);
 
 
 
 
 
911
  }
912
+ // Sort: semantic first, then palette
913
+ var semOrder = ['brand', 'text', 'bg', 'background', 'border', 'feedback'];
 
914
  var sortedGroups = [];
915
+ for (var so = 0; so < semOrder.length; so++) { if (colorGroups[semOrder[so]]) sortedGroups.push(semOrder[so]); }
916
+ for (var go = 0; go < groupOrder.length; go++) { if (sortedGroups.indexOf(groupOrder[go]) === -1) sortedGroups.push(groupOrder[go]); }
917
+
918
+ // Calculate height: each group = heading(40) + one row of swatches(SWATCH_H_COLOR + SWATCH_META_H + 16)
919
+ var colorsPerRow = Math.floor((CONTENT_W + SWATCH_GAP) / (SWATCH_W + SWATCH_GAP));
920
+ var totalColorH = SECTION_PAD;
921
+ for (var cgi = 0; cgi < sortedGroups.length; cgi++) {
922
+ var rows = Math.ceil(colorGroups[sortedGroups[cgi]].length / colorsPerRow);
923
+ totalColorH += 40 + rows * (SWATCH_H_COLOR + SWATCH_META_H + ITEM_GAP) + 16;
924
  }
925
+ totalColorH += SECTION_PAD;
926
 
927
+ var colorFrame = createSectionFrame('Colors', totalColorH);
928
+ var cy = SECTION_PAD;
929
+
930
+ // Section title
931
+ addText(colorFrame, 'COLORS', boldFont, 32, MARGIN, cy, DARK);
932
+ cy += 48;
933
 
934
  for (var gi = 0; gi < sortedGroups.length; gi++) {
935
+ var gName = sortedGroups[gi];
936
+ var gColors = colorGroups[gName];
937
 
938
+ // Group heading
939
+ addText(colorFrame, gName.charAt(0).toUpperCase() + gName.slice(1), boldFont, 18, MARGIN, cy, DARK);
940
+ cy += 36;
941
 
942
+ for (var ci2 = 0; ci2 < gColors.length; ci2++) {
943
+ var ct = gColors[ci2];
944
+ var col = ci2 % colorsPerRow;
945
+ var row = Math.floor(ci2 / colorsPerRow);
946
+ var sx = MARGIN + col * (SWATCH_W + SWATCH_GAP);
947
+ var sy = cy + row * (SWATCH_H_COLOR + SWATCH_META_H + ITEM_GAP);
948
 
949
+ // Color fill rectangle (top part of card)
950
  var swatch = figma.createRectangle();
951
+ swatch.resize(SWATCH_W, SWATCH_H_COLOR);
952
+ swatch.x = sx; swatch.y = sy;
953
+ var tl = 8, tr = 8, bl = 0, br = 0;
954
+ swatch.topLeftRadius = tl; swatch.topRightRadius = tr;
955
+ swatch.bottomLeftRadius = bl; swatch.bottomRightRadius = br;
956
+ swatch.fills = [{ type: 'SOLID', color: hexToRgb(ct.value) }];
957
+ colorFrame.appendChild(swatch);
958
+
959
+ // AA badge on swatch
960
+ var aa = getAAResult(ct.value);
961
+ var badgeText = aa.passAA ? 'AA ✓ ' + aa.ratio + ':1' : 'AA ✗ ' + aa.ratio + ':1';
962
+ var badgeColor = aa.bestOn === 'white' ? { r: 1, g: 1, b: 1 } : { r: 0, g: 0, b: 0 };
963
+ addText(colorFrame, badgeText, labelFont, 10, sx + 8, sy + SWATCH_H_COLOR - 18, badgeColor);
964
+
965
+ // Metadata area (below swatch)
966
+ var metaBg = figma.createRectangle();
967
+ metaBg.resize(SWATCH_W, SWATCH_META_H);
968
+ metaBg.x = sx; metaBg.y = sy + SWATCH_H_COLOR;
969
+ metaBg.bottomLeftRadius = 8; metaBg.bottomRightRadius = 8;
970
+ metaBg.fills = [{ type: 'SOLID', color: { r: 0.97, g: 0.97, b: 0.98 } }];
971
+ metaBg.strokes = [{ type: 'SOLID', color: { r: 0.9, g: 0.9, b: 0.9 } }];
972
+ metaBg.strokeWeight = 1;
973
+ colorFrame.appendChild(metaBg);
974
+
975
+ // Token name
976
  var nameParts = ct.name.split('/');
977
+ var displayName = nameParts.filter(function(p) { return p !== 'DEFAULT'; }).join('/');
978
+ addText(colorFrame, displayName, boldFont, 11, sx + 10, sy + SWATCH_H_COLOR + 8, DARK);
 
 
 
 
 
 
 
 
 
979
 
980
  // Hex value
981
+ addText(colorFrame, ct.value.toUpperCase(), labelFont, 11, sx + 10, sy + SWATCH_H_COLOR + 26, MUTED);
982
+
983
+ // Contrast info
984
+ var contrastText = aa.passAA ? 'AA Pass on ' + aa.bestOn : 'AA Fail (' + aa.ratio + ':1)';
985
+ var contrastColor = aa.passAA ? { r: 0.13, g: 0.55, b: 0.13 } : { r: 0.85, g: 0.18, b: 0.18 };
986
+ addText(colorFrame, contrastText, labelFont, 10, sx + 10, sy + SWATCH_H_COLOR + 44, contrastColor);
 
 
 
 
987
  }
988
 
989
+ var gRows = Math.ceil(gColors.length / colorsPerRow);
990
+ cy += gRows * (SWATCH_H_COLOR + SWATCH_META_H + ITEM_GAP) + 16;
991
  }
 
 
992
  }
993
 
994
+ // ══════════════════════════════════════════════════════════
995
+ // TYPOGRAPHY DESKTOP FRAME
996
+ // ══════════════════════════════════════════════════════════
997
+ var desktopTypo = [];
998
+ var mobileTypo = [];
999
+ for (var tti = 0; tti < tokens.typography.length; tti++) {
1000
+ if (tokens.typography[tti].name.toLowerCase().indexOf('mobile') > -1) {
1001
+ mobileTypo.push(tokens.typography[tti]);
1002
+ } else {
1003
+ desktopTypo.push(tokens.typography[tti]);
 
 
 
 
 
 
 
1004
  }
1005
+ }
1006
 
1007
+ function getSampleText(name) {
1008
+ var n = name.toLowerCase();
1009
+ if (n.indexOf('display') > -1) return 'The quick brown fox jumps over the lazy dog';
1010
+ if (n.indexOf('heading') > -1) return 'The quick brown fox jumps over';
1011
+ if (n.indexOf('body') > -1) return 'The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs.';
1012
+ if (n.indexOf('caption') > -1) return 'The quick brown fox jumps over the lazy dog';
1013
+ if (n.indexOf('overline') > -1) return 'THE QUICK BROWN FOX JUMPS';
1014
+ return 'The quick brown fox jumps over the lazy dog';
1015
+ }
 
1016
 
1017
+ function renderTypoFrame(typoList, frameName) {
1018
+ if (typoList.length === 0) return;
 
 
 
 
1019
 
1020
+ // Estimate height: each row ~ 80-120px depending on font size
1021
+ var estH = SECTION_PAD + 60;
1022
+ for (var i = 0; i < typoList.length; i++) {
1023
+ var fs = parseNumericValue(typoList[i].value.fontSize) || 16;
1024
+ estH += Math.max(Math.min(fs, 56), 24) + 64; // sample + labels + gap
1025
+ }
1026
+ estH += SECTION_PAD;
1027
+
1028
+ var frame = createSectionFrame(frameName, estH);
1029
+ var fy = SECTION_PAD;
1030
+
1031
+ addText(frame, frameName.toUpperCase(), boldFont, 32, MARGIN, fy, DARK);
1032
+ fy += 52;
1033
+
1034
+ // Column headers
1035
+ var COL_NAME = MARGIN;
1036
+ var COL_SAMPLE = MARGIN + 180;
1037
+ var COL_SPECS = MARGIN + 760;
1038
+
1039
+ addText(frame, 'Token', boldFont, 12, COL_NAME, fy, MUTED);
1040
+ addText(frame, 'Sample', boldFont, 12, COL_SAMPLE, fy, MUTED);
1041
+ addText(frame, 'Specifications', boldFont, 12, COL_SPECS, fy, MUTED);
1042
+ fy += 28;
1043
+
1044
+ // Divider
1045
+ var div = figma.createRectangle();
1046
+ div.resize(CONTENT_W, 1); div.x = MARGIN; div.y = fy;
1047
+ div.fills = [{ type: 'SOLID', color: { r: 0.88, g: 0.88, b: 0.88 } }];
1048
+ frame.appendChild(div);
1049
+ fy += 16;
1050
+
1051
+ for (var ti = 0; ti < typoList.length; ti++) {
1052
+ var tt = typoList[ti];
1053
+ var val = tt.value;
1054
+ var fFamily = (val.fontFamily || 'Inter').split(',')[0].trim();
1055
+ var fSize = parseNumericValue(val.fontSize) || 16;
1056
+ var fWeight = val.fontWeight || '400';
1057
+ var fLH = val.lineHeight || '1.5';
1058
+ var displaySize = Math.min(fSize, 56);
1059
+
1060
+ // Token name column
1061
+ var tierParts = tt.name.split('/');
1062
+ var tierName = tierParts.filter(function(p) { return p !== 'desktop' && p !== 'mobile'; }).join('.');
1063
+ addText(frame, tierName, boldFont, 13, COL_NAME, fy + 4, DARK);
1064
+
1065
+ // Sample text in actual font
1066
+ var useBold = (tierName.indexOf('display') > -1 || tierName.indexOf('heading') > -1);
1067
+ var sample = figma.createText();
1068
+ sample.fontName = useBold ? sampleFontBold : sampleFont;
1069
+ sample.fontSize = displaySize;
1070
+ sample.characters = getSampleText(tt.name);
1071
+ sample.x = COL_SAMPLE; sample.y = fy;
1072
+ sample.resize(540, sample.height);
1073
+ sample.textAutoResize = 'HEIGHT';
1074
+ frame.appendChild(sample);
1075
+
1076
+ // Specs column stacked chips
1077
+ var specY = fy;
1078
+ addText(frame, 'Size: ' + fSize + 'px', boldFont, 12, COL_SPECS, specY, DARK);
1079
+ specY += 18;
1080
+ addText(frame, 'Weight: ' + fWeight, labelFont, 12, COL_SPECS, specY, MUTED);
1081
+ specY += 18;
1082
+ addText(frame, 'Line Height: ' + fLH, labelFont, 12, COL_SPECS, specY, MUTED);
1083
+ specY += 18;
1084
+ addText(frame, 'Font: ' + fFamily, labelFont, 12, COL_SPECS, specY, BLUE);
1085
+
1086
+ var rowH = Math.max(displaySize + 8, specY - fy + 24);
1087
+ fy += rowH + 8;
1088
+
1089
+ // Row separator
1090
+ var sep = figma.createRectangle();
1091
+ sep.resize(CONTENT_W, 1); sep.x = MARGIN; sep.y = fy;
1092
+ sep.fills = [{ type: 'SOLID', color: { r: 0.94, g: 0.94, b: 0.94 } }];
1093
+ frame.appendChild(sep);
1094
+ fy += 12;
1095
  }
1096
 
1097
+ // Resize frame to actual content
1098
+ frame.resize(FRAME_W, fy + SECTION_PAD);
 
 
 
1099
  }
1100
 
1101
+ renderTypoFrame(desktopTypo, 'Typography — Desktop');
1102
+ renderTypoFrame(mobileTypo, 'Typography — Mobile');
 
 
 
1103
 
1104
+ // ══════════════════════════════════════════════════════════
1105
+ // SPACING FRAME — Desktop & Mobile side by side, bars not squares
1106
+ // ══════════════════════════════════════════════════════════
1107
+ if (tokens.spacing.length > 0) {
1108
+ var dSpacing = [];
1109
+ var mSpacing = [];
1110
  for (var spi = 0; spi < tokens.spacing.length; spi++) {
1111
+ if (tokens.spacing[spi].name.toLowerCase().indexOf('mobile') > -1) {
1112
+ mSpacing.push(tokens.spacing[spi]);
 
 
1113
  } else {
1114
+ dSpacing.push(tokens.spacing[spi]);
1115
  }
1116
  }
1117
 
1118
+ var maxItems = Math.max(dSpacing.length, mSpacing.length);
1119
+ var spFrameH = SECTION_PAD + 60 + maxItems * 48 + SECTION_PAD;
1120
+
1121
+ var spFrame = createSectionFrame('Spacing', spFrameH);
1122
+ var spy = SECTION_PAD;
1123
+
1124
+ addText(spFrame, 'SPACING', boldFont, 32, MARGIN, spy, DARK);
1125
+ spy += 52;
1126
+
1127
+ // Render spacing column as horizontal bars (Carbon-style)
1128
+ function renderSpacingBars(list, startX, title, y0) {
1129
+ addText(spFrame, title, boldFont, 16, startX, y0, BLUE);
1130
+ var ly = y0 + 32;
1131
+
1132
+ for (var si = 0; si < Math.min(list.length, 12); si++) {
1133
+ var sp = list[si];
1134
  var spVal = parseNumericValue(sp.value);
1135
+ var barW = Math.max(spVal * 3, 8); // Scale 3x for visibility
1136
+ barW = Math.min(barW, 400); // Cap
1137
+
1138
+ // Token label
 
 
 
 
 
 
 
 
 
1139
  var spParts = sp.name.split('/');
1140
  var spDisplayName = spParts.filter(function(p) { return p !== 'desktop' && p !== 'mobile'; }).join('/');
1141
+ addText(spFrame, spDisplayName, labelFont, 12, startX, ly + 4, DARK);
1142
+
1143
+ // Bar
1144
+ var bar = figma.createRectangle();
1145
+ bar.resize(barW, 24);
1146
+ bar.x = startX + 120;
1147
+ bar.y = ly;
1148
+ bar.cornerRadius = 4;
1149
+ bar.fills = [{ type: 'SOLID', color: { r: 0.2, g: 0.55, b: 1 } }];
1150
+ spFrame.appendChild(bar);
1151
+
1152
+ // Value label
1153
+ addText(spFrame, sp.value, labelFont, 12, startX + 120 + barW + 12, ly + 4, MUTED);
1154
+
1155
+ ly += 40;
1156
  }
1157
+ return ly;
1158
  }
1159
 
1160
+ var dEnd = renderSpacingBars(dSpacing, MARGIN, '🖥 Desktop', spy);
1161
+ var mEnd = renderSpacingBars(mSpacing, MARGIN + 560, '📱 Mobile', spy);
1162
 
1163
+ spFrame.resize(FRAME_W, Math.max(dEnd, mEnd) + SECTION_PAD);
1164
  }
1165
 
1166
+ // ══════════════════════════════════════════════════════════
1167
+ // BORDER RADIUS FRAME
1168
+ // ══════════════════════════════════════════════════════════
1169
  if (tokens.borderRadius.length > 0) {
1170
+ var radFrameH = SECTION_PAD + 60 + 120 + SECTION_PAD;
1171
+ var radFrame = createSectionFrame('Border Radius', radFrameH);
1172
+ var ry = SECTION_PAD;
1173
+
1174
+ addText(radFrame, 'BORDER RADIUS', boldFont, 32, MARGIN, ry, DARK);
1175
+ ry += 52;
1176
 
1177
+ var rx = MARGIN;
1178
  for (var ri = 0; ri < tokens.borderRadius.length; ri++) {
1179
+ var rToken = tokens.borderRadius[ri];
1180
+ var rVal = parseNumericValue(rToken.value);
1181
 
1182
  var rect = figma.createRectangle();
1183
+ rect.resize(64, 64);
1184
+ rect.x = rx; rect.y = ry;
1185
+ rect.cornerRadius = Math.min(rVal, 32);
1186
+ rect.fills = [{ type: 'SOLID', color: { r: 0.93, g: 0.93, b: 0.96 } }];
1187
+ rect.strokes = [{ type: 'SOLID', color: { r: 0.75, g: 0.75, b: 0.82 } }];
 
1188
  rect.strokeWeight = 2;
1189
+ radFrame.appendChild(rect);
1190
+
1191
+ // Name
1192
+ addText(radFrame, rToken.name.split('/').pop(), boldFont, 11, rx, ry + 72, DARK);
1193
+ // Value
1194
+ addText(radFrame, rToken.value, labelFont, 11, rx, ry + 88, MUTED);
 
 
 
 
 
 
 
1195
 
1196
+ rx += 90;
1197
+ }
1198
  }
1199
 
1200
+ // ══════════════════════════════════════════════════════════
1201
+ // SHADOWS FRAME — on light gray background (MD pattern)
1202
+ // ══════════════════════════════════════════════════════════
1203
  if (tokens.shadows.length > 0) {
1204
+ var shFrameH = SECTION_PAD + 60 + 200 + SECTION_PAD;
1205
+ var shFrame = createSectionFrame('Shadows', shFrameH);
1206
+ // Light gray background for shadow visibility
1207
+ shFrame.fills = [{ type: 'SOLID', color: { r: 0.96, g: 0.96, b: 0.96 } }];
 
 
1208
 
1209
+ var shy = SECTION_PAD;
1210
+ addText(shFrame, 'SHADOWS / ELEVATION', boldFont, 32, MARGIN, shy, DARK);
1211
+ shy += 52;
 
 
1212
 
1213
+ var shx = MARGIN;
1214
+ for (var shi = 0; shi < tokens.shadows.length; shi++) {
1215
+ var shToken = tokens.shadows[shi];
1216
+ var sv = shToken.value;
1217
+ var oxV = parseNumericValue(sv.offsetX || sv.x || '0');
1218
+ var oyV = parseNumericValue(sv.offsetY || sv.y || '0');
1219
+ var blV = parseNumericValue(sv.blur || '0');
1220
+ var spV = parseNumericValue(sv.spread || '0');
1221
+ var shC = parseColorToRGBA(sv.color || 'rgba(0,0,0,0.25)');
1222
+
1223
+ // White card with shadow
1224
  var card = figma.createRectangle();
1225
+ card.resize(140, 140);
1226
+ card.x = shx; card.y = shy;
 
1227
  card.cornerRadius = 12;
1228
  card.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1 } }];
1229
  card.effects = [{
1230
  type: 'DROP_SHADOW',
1231
+ color: { r: shC.r, g: shC.g, b: shC.b, a: shC.a },
1232
+ offset: { x: oxV, y: oyV },
1233
+ radius: blV,
1234
+ spread: spV,
1235
  visible: true,
1236
  blendMode: 'NORMAL'
1237
  }];
1238
+ shFrame.appendChild(card);
 
 
 
 
 
 
 
 
 
 
 
 
 
1239
 
1240
+ // Level name centered on card
1241
+ addText(shFrame, shToken.name.split('/').pop(), boldFont, 14, shx + 20, shy + 30, DARK);
1242
+
1243
+ // Specs on card
1244
+ addText(shFrame, 'blur: ' + blV + 'px', labelFont, 11, shx + 20, shy + 56, MUTED);
1245
+ addText(shFrame, 'y: ' + oyV + 'px', labelFont, 11, shx + 20, shy + 72, MUTED);
1246
+ addText(shFrame, 'spread: ' + spV + 'px', labelFont, 11, shx + 20, shy + 88, MUTED);
1247
 
1248
+ shx += 180;
1249
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1250
  }
1251
 
1252
+ figma.ui.postMessage({ type: 'spec-complete', message: 'Visual spec created with ' + specPage.children.length + ' section frames!' });
 
 
 
1253
 
1254
  } catch (error) {
1255
  console.error('Error creating visual spec:', error);