dembrandt 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,15 @@
6
6
 
7
7
  Extract any website’s design system into design tokens in a few seconds: logo, colors, typography, borders, and more. One command.
8
8
 
9
- ![Dembrandt Demo](showcase.png)
9
+ ![Dembrandt — Any website to design tokens](docs/screenshots/banner.png)
10
+
11
+ **CLI output**
12
+
13
+ ![CLI extraction of netflix.com](docs/screenshots/cli-output.png)
14
+
15
+ **Local UI**
16
+
17
+ ![Local UI showing extracted brand](docs/screenshots/local-ui.png)
10
18
 
11
19
  ## Install
12
20
 
@@ -82,6 +90,37 @@ dembrandt stripe.com --dtcg
82
90
 
83
91
  The DTCG format is an industry-standard JSON schema that can be consumed by design tools and token transformation libraries like [Style Dictionary](https://styledictionary.com).
84
92
 
93
+ ## Local UI
94
+
95
+ Browse your extracted brands in a visual interface.
96
+
97
+ ### Setup
98
+
99
+ ```bash
100
+ cd local-ui
101
+ npm install
102
+ ```
103
+
104
+ ### Running
105
+
106
+ ```bash
107
+ npm start
108
+ ```
109
+
110
+ Opens http://localhost:5173 with API on port 3002.
111
+
112
+ ### Features
113
+
114
+ - Visual grid of all extracted brands
115
+ - Color palettes with click-to-copy
116
+ - Typography specimens
117
+ - Spacing, shadows, border radius visualization
118
+ - Button and link component previews
119
+ - Dark/light theme toggle
120
+ - Section nav links on extraction pages — jump directly to Colors, Typography, Shadows, etc. via a sticky sidebar
121
+
122
+ Extractions are performed via CLI (`dembrandt <url> --save-output`) and automatically appear in the UI.
123
+
85
124
  ## Use Cases
86
125
 
87
126
  - Brand audits & competitive analysis
package/index.js CHANGED
@@ -14,13 +14,14 @@ import { chromium, firefox } from "playwright-core";
14
14
  import { extractBranding } from "./lib/extractors.js";
15
15
  import { displayResults } from "./lib/display.js";
16
16
  import { toW3CFormat } from "./lib/w3c-exporter.js";
17
+ import { generatePDF } from "./lib/pdf.js";
17
18
  import { writeFileSync, mkdirSync } from "fs";
18
19
  import { join } from "path";
19
20
 
20
21
  program
21
22
  .name("dembrandt")
22
23
  .description("Extract design tokens from any website")
23
- .version("0.6.0")
24
+ .version("0.7.0")
24
25
  .argument("<url>")
25
26
  .option("--browser <type>", "Browser to use (chromium|firefox)", "chromium")
26
27
  .option("--json-only", "Output raw JSON")
@@ -29,6 +30,7 @@ program
29
30
  .option("--dark-mode", "Extract colors from dark mode")
30
31
  .option("--mobile", "Extract from mobile viewport")
31
32
  .option("--slow", "3x longer timeouts for slow-loading sites")
33
+ .option("--brand-guide", "Export a brand guide PDF")
32
34
  .option("--no-sandbox", "Disable browser sandbox (needed for Docker/CI)")
33
35
  .action(async (input, opts) => {
34
36
  let url = input;
@@ -99,7 +101,7 @@ program
99
101
  const outputData = opts.dtcg ? toW3CFormat(result) : result;
100
102
 
101
103
  // Save JSON output if --save-output or --dtcg is specified
102
- if ((opts.saveOutput || opts.dtcg) && !opts.jsonOnly) {
104
+ if (opts.saveOutput || opts.dtcg) {
103
105
  try {
104
106
  const domain = new URL(url).hostname.replace("www.", "");
105
107
  const timestamp = new Date()
@@ -129,6 +131,35 @@ program
129
131
  }
130
132
  }
131
133
 
134
+ // Generate PDF brand guide
135
+ if (opts.brandGuide) {
136
+ try {
137
+ const pdfDomain = new URL(url).hostname.replace("www.", "");
138
+ const now = new Date();
139
+ const pdfDate = now.toISOString().slice(0, 10);
140
+ const pdfTime = `${String(now.getHours()).padStart(2, '0')}-${String(now.getMinutes()).padStart(2, '0')}`;
141
+ const pdfDir = join(process.cwd(), "output", pdfDomain);
142
+ mkdirSync(pdfDir, { recursive: true });
143
+ const pdfFilename = `${pdfDomain}-brand-guide-${pdfDate}-${pdfTime}.pdf`;
144
+ const pdfPath = join(pdfDir, pdfFilename);
145
+ spinner.start("Generating PDF brand guide...");
146
+ await generatePDF(result, pdfPath, browser);
147
+ spinner.stop();
148
+ console.log(
149
+ chalk.dim(
150
+ `PDF saved to: ${chalk.hex('#8BE9FD')(
151
+ `output/${pdfDomain}/${pdfFilename}`
152
+ )}`
153
+ )
154
+ );
155
+ } catch (err) {
156
+ spinner.stop();
157
+ console.log(
158
+ chalk.hex('#FFB86C')(`Could not generate PDF: ${err.message}`)
159
+ );
160
+ }
161
+ }
162
+
132
163
  // Output to terminal
133
164
  if (opts.jsonOnly) {
134
165
  console.log(JSON.stringify(outputData, null, 2));
package/lib/colors.js ADDED
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Color Conversion Utilities
3
+ *
4
+ * Converts colors between RGB, LCH, and OKLCH color spaces.
5
+ */
6
+
7
+ /**
8
+ * Convert sRGB to linear RGB
9
+ */
10
+ function srgbToLinear(c) {
11
+ c = c / 255;
12
+ return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
13
+ }
14
+
15
+ /**
16
+ * Convert linear RGB to XYZ (D65 illuminant)
17
+ */
18
+ function linearRgbToXyz(r, g, b) {
19
+ return {
20
+ x: 0.4124564 * r + 0.3575761 * g + 0.1804375 * b,
21
+ y: 0.2126729 * r + 0.7151522 * g + 0.0721750 * b,
22
+ z: 0.0193339 * r + 0.1191920 * g + 0.9503041 * b
23
+ };
24
+ }
25
+
26
+ /**
27
+ * Convert XYZ to Lab (D65 reference white)
28
+ */
29
+ function xyzToLab(x, y, z) {
30
+ // D65 reference white
31
+ const xn = 0.95047;
32
+ const yn = 1.00000;
33
+ const zn = 1.08883;
34
+
35
+ const f = (t) => t > 0.008856 ? Math.cbrt(t) : (903.3 * t + 16) / 116;
36
+
37
+ const fx = f(x / xn);
38
+ const fy = f(y / yn);
39
+ const fz = f(z / zn);
40
+
41
+ return {
42
+ l: 116 * fy - 16,
43
+ a: 500 * (fx - fy),
44
+ b: 200 * (fy - fz)
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Convert Lab to LCH
50
+ */
51
+ function labToLch(l, a, b) {
52
+ const c = Math.sqrt(a * a + b * b);
53
+ let h = Math.atan2(b, a) * (180 / Math.PI);
54
+ if (h < 0) h += 360;
55
+
56
+ return {
57
+ l: l,
58
+ c: c,
59
+ h: h
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Convert RGB to LCH
65
+ * @param {number} r - Red (0-255)
66
+ * @param {number} g - Green (0-255)
67
+ * @param {number} b - Blue (0-255)
68
+ * @returns {{ l: number, c: number, h: number }}
69
+ */
70
+ export function rgbToLch(r, g, b) {
71
+ const lr = srgbToLinear(r);
72
+ const lg = srgbToLinear(g);
73
+ const lb = srgbToLinear(b);
74
+
75
+ const xyz = linearRgbToXyz(lr, lg, lb);
76
+ const lab = xyzToLab(xyz.x, xyz.y, xyz.z);
77
+ return labToLch(lab.l, lab.a, lab.b);
78
+ }
79
+
80
+ /**
81
+ * Convert linear RGB to OKLab
82
+ * Uses the OKLab color space for perceptual uniformity
83
+ */
84
+ function linearRgbToOklab(r, g, b) {
85
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
86
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
87
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
88
+
89
+ return {
90
+ L: 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
91
+ a: 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
92
+ b: 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Convert OKLab to OKLCH
98
+ */
99
+ function oklabToOklch(L, a, b) {
100
+ const c = Math.sqrt(a * a + b * b);
101
+ let h = Math.atan2(b, a) * (180 / Math.PI);
102
+ if (h < 0) h += 360;
103
+
104
+ return {
105
+ l: L,
106
+ c: c,
107
+ h: h
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Convert RGB to OKLCH
113
+ * @param {number} r - Red (0-255)
114
+ * @param {number} g - Green (0-255)
115
+ * @param {number} b - Blue (0-255)
116
+ * @returns {{ l: number, c: number, h: number }}
117
+ */
118
+ export function rgbToOklch(r, g, b) {
119
+ const lr = srgbToLinear(r);
120
+ const lg = srgbToLinear(g);
121
+ const lb = srgbToLinear(b);
122
+
123
+ const oklab = linearRgbToOklab(lr, lg, lb);
124
+ return oklabToOklch(oklab.L, oklab.a, oklab.b);
125
+ }
126
+
127
+ /**
128
+ * Format LCH values as CSS lch() string
129
+ * @param {{ l: number, c: number, h: number }} lch
130
+ * @param {number} [alpha] - Optional alpha value (0-1)
131
+ * @returns {string}
132
+ */
133
+ export function formatLch(lch, alpha) {
134
+ const l = Math.round(lch.l * 100) / 100;
135
+ const c = Math.round(lch.c * 100) / 100;
136
+ const h = Math.round(lch.h * 100) / 100;
137
+
138
+ if (alpha !== undefined && alpha < 1) {
139
+ return `lch(${l}% ${c} ${h} / ${alpha})`;
140
+ }
141
+ return `lch(${l}% ${c} ${h})`;
142
+ }
143
+
144
+ /**
145
+ * Format OKLCH values as CSS oklch() string
146
+ * @param {{ l: number, c: number, h: number }} oklch
147
+ * @param {number} [alpha] - Optional alpha value (0-1)
148
+ * @returns {string}
149
+ */
150
+ export function formatOklch(oklch, alpha) {
151
+ // OKLCH lightness is 0-1, displayed as percentage
152
+ const l = Math.round(oklch.l * 10000) / 100;
153
+ const c = Math.round(oklch.c * 1000) / 1000;
154
+ const h = Math.round(oklch.h * 100) / 100;
155
+
156
+ if (alpha !== undefined && alpha < 1) {
157
+ return `oklch(${l}% ${c} ${h} / ${alpha})`;
158
+ }
159
+ return `oklch(${l}% ${c} ${h})`;
160
+ }
161
+
162
+ /**
163
+ * Parse a hex color string and return RGB values
164
+ * @param {string} hex - Hex color (#fff, #ffffff, #ffffffaa)
165
+ * @returns {{ r: number, g: number, b: number, a?: number } | null}
166
+ */
167
+ export function hexToRgb(hex) {
168
+ if (!hex || !hex.startsWith('#')) return null;
169
+
170
+ // Remove #
171
+ hex = hex.slice(1);
172
+
173
+ // Handle 3-digit hex
174
+ if (hex.length === 3) {
175
+ return {
176
+ r: parseInt(hex[0] + hex[0], 16),
177
+ g: parseInt(hex[1] + hex[1], 16),
178
+ b: parseInt(hex[2] + hex[2], 16)
179
+ };
180
+ }
181
+
182
+ // Handle 6-digit hex
183
+ if (hex.length === 6) {
184
+ return {
185
+ r: parseInt(hex.slice(0, 2), 16),
186
+ g: parseInt(hex.slice(2, 4), 16),
187
+ b: parseInt(hex.slice(4, 6), 16)
188
+ };
189
+ }
190
+
191
+ // Handle 8-digit hex (with alpha)
192
+ if (hex.length === 8) {
193
+ return {
194
+ r: parseInt(hex.slice(0, 2), 16),
195
+ g: parseInt(hex.slice(2, 4), 16),
196
+ b: parseInt(hex.slice(4, 6), 16),
197
+ a: parseInt(hex.slice(6, 8), 16) / 255
198
+ };
199
+ }
200
+
201
+ return null;
202
+ }
203
+
204
+ /**
205
+ * Convert any supported color format to all formats
206
+ * @param {string} colorString - Color in hex, rgb(), or rgba() format
207
+ * @returns {{ hex: string, rgb: string, lch: string, oklch: string, hasAlpha: boolean } | null}
208
+ */
209
+ export function convertColor(colorString) {
210
+ let r, g, b, a;
211
+
212
+ // Parse rgba/rgb
213
+ const rgbaMatch = colorString.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
214
+ if (rgbaMatch) {
215
+ r = parseInt(rgbaMatch[1]);
216
+ g = parseInt(rgbaMatch[2]);
217
+ b = parseInt(rgbaMatch[3]);
218
+ a = rgbaMatch[4] ? parseFloat(rgbaMatch[4]) : undefined;
219
+ } else {
220
+ // Try hex
221
+ const rgb = hexToRgb(colorString);
222
+ if (!rgb) return null;
223
+ r = rgb.r;
224
+ g = rgb.g;
225
+ b = rgb.b;
226
+ a = rgb.a;
227
+ }
228
+
229
+ const hex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
230
+ const rgbStr = a !== undefined ? `rgba(${r}, ${g}, ${b}, ${a})` : `rgb(${r}, ${g}, ${b})`;
231
+
232
+ const lchValues = rgbToLch(r, g, b);
233
+ const oklchValues = rgbToOklch(r, g, b);
234
+
235
+ return {
236
+ hex: hex.toLowerCase(),
237
+ rgb: rgbStr,
238
+ lch: formatLch(lchValues, a),
239
+ oklch: formatOklch(oklchValues, a),
240
+ hasAlpha: a !== undefined && a < 1
241
+ };
242
+ }
package/lib/display.js CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import chalk from 'chalk';
9
+ import { convertColor } from './colors.js';
9
10
 
10
11
  /**
11
12
  * Creates a clickable terminal link using ANSI escape codes
@@ -94,65 +95,20 @@ function displayFavicons(favicons) {
94
95
  }
95
96
 
96
97
  function normalizeColorFormat(colorString) {
97
- // Return both hex and rgb formats
98
- const rgbaMatch = colorString.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
99
- if (rgbaMatch) {
100
- const r = parseInt(rgbaMatch[1]);
101
- const g = parseInt(rgbaMatch[2]);
102
- const b = parseInt(rgbaMatch[3]);
103
- const a = rgbaMatch[4];
104
-
105
- const hex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
106
- const rgb = a ? `rgba(${r}, ${g}, ${b}, ${a})` : `rgb(${r}, ${g}, ${b})`;
107
-
108
- return { hex, rgb, hasAlpha: !!a };
98
+ // Use the centralized color conversion utility
99
+ const converted = convertColor(colorString);
100
+ if (converted) {
101
+ return converted;
109
102
  }
110
103
 
111
- // Match 3-digit hex (#fff, #f0a, etc.)
112
- const hexMatch3 = colorString.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i);
113
- if (hexMatch3) {
114
- // Expand 3-digit to 6-digit (#fff → #ffffff)
115
- const r = parseInt(hexMatch3[1] + hexMatch3[1], 16);
116
- const g = parseInt(hexMatch3[2] + hexMatch3[2], 16);
117
- const b = parseInt(hexMatch3[3] + hexMatch3[3], 16);
118
-
119
- return {
120
- hex: `#${hexMatch3[1]}${hexMatch3[1]}${hexMatch3[2]}${hexMatch3[2]}${hexMatch3[3]}${hexMatch3[3]}`.toLowerCase(),
121
- rgb: `rgb(${r}, ${g}, ${b})`,
122
- hasAlpha: false
123
- };
124
- }
125
-
126
- // Match 8-digit hex with alpha (#ffffff80, #00ff00ff, etc.)
127
- const hexMatch8 = colorString.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
128
- if (hexMatch8) {
129
- const r = parseInt(hexMatch8[1], 16);
130
- const g = parseInt(hexMatch8[2], 16);
131
- const b = parseInt(hexMatch8[3], 16);
132
- const a = (parseInt(hexMatch8[4], 16) / 255).toFixed(2);
133
-
134
- return {
135
- hex: `#${hexMatch8[1]}${hexMatch8[2]}${hexMatch8[3]}`.toLowerCase(),
136
- rgb: `rgba(${r}, ${g}, ${b}, ${a})`,
137
- hasAlpha: true
138
- };
139
- }
140
-
141
- // Match 6-digit hex (#ffffff, #f0a0b0, etc.)
142
- const hexMatch6 = colorString.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
143
- if (hexMatch6) {
144
- const r = parseInt(hexMatch6[1], 16);
145
- const g = parseInt(hexMatch6[2], 16);
146
- const b = parseInt(hexMatch6[3], 16);
147
-
148
- return {
149
- hex: colorString.toLowerCase(),
150
- rgb: `rgb(${r}, ${g}, ${b})`,
151
- hasAlpha: false
152
- };
153
- }
154
-
155
- return { hex: colorString, rgb: colorString, hasAlpha: false };
104
+ // Fallback for unparseable colors
105
+ return {
106
+ hex: colorString,
107
+ rgb: colorString,
108
+ lch: colorString,
109
+ oklch: colorString,
110
+ hasAlpha: false
111
+ };
156
112
  }
157
113
 
158
114
  function displayColors(colors) {
@@ -170,6 +126,8 @@ function displayColors(colors) {
170
126
  allColors.push({
171
127
  hex: formats.hex,
172
128
  rgb: formats.rgb,
129
+ lch: formats.lch,
130
+ oklch: formats.oklch,
173
131
  hasAlpha: formats.hasAlpha,
174
132
  label: role,
175
133
  type: 'semantic',
@@ -181,12 +139,18 @@ function displayColors(colors) {
181
139
  // Add CSS variables
182
140
  if (colors.cssVariables) {
183
141
  const limit = 15;
184
- Object.entries(colors.cssVariables).slice(0, limit).forEach(([name, value]) => {
142
+ Object.entries(colors.cssVariables).slice(0, limit).forEach(([name, varData]) => {
185
143
  try {
186
- const formats = normalizeColorFormat(value);
144
+ // Handle both old format (string) and new format (object with value, lch, oklch)
145
+ const colorValue = typeof varData === 'string' ? varData : varData.value;
146
+ const formats = normalizeColorFormat(colorValue);
147
+
148
+ // Use pre-computed LCH/OKLCH from extractor if available
187
149
  allColors.push({
188
150
  hex: formats.hex,
189
151
  rgb: formats.rgb,
152
+ lch: (typeof varData === 'object' && varData.lch) || formats.lch,
153
+ oklch: (typeof varData === 'object' && varData.oklch) || formats.oklch,
190
154
  hasAlpha: formats.hasAlpha,
191
155
  label: name,
192
156
  type: 'variable',
@@ -208,6 +172,8 @@ function displayColors(colors) {
208
172
  allColors.push({
209
173
  hex: formats.hex,
210
174
  rgb: formats.rgb,
175
+ lch: c.lch || formats.lch,
176
+ oklch: c.oklch || formats.oklch,
211
177
  hasAlpha: formats.hasAlpha,
212
178
  label: '',
213
179
  type: 'palette',
@@ -244,10 +210,11 @@ function displayColors(colors) {
244
210
 
245
211
  const uniqueColors = Array.from(colorMap.values());
246
212
 
247
- // Display all colors with both hex and RGB in grid format
248
- uniqueColors.forEach(({ hex, rgb, label, confidence }, index) => {
213
+ // Display all colors with hex, RGB, LCH, and OKLCH formats
214
+ uniqueColors.forEach(({ hex, rgb, lch, oklch, label, confidence }, index) => {
249
215
  const isLast = index === uniqueColors.length - 1;
250
216
  const branch = isLast ? '└─' : '├─';
217
+ const indent = isLast ? ' ' : '│ ';
251
218
 
252
219
  try {
253
220
  const colorBlock = chalk.bgHex(hex)(' ');
@@ -256,12 +223,19 @@ function displayColors(colors) {
256
223
  else if (confidence === 'medium') conf = chalk.hex('#FFB86C')('●');
257
224
  else conf = chalk.gray('●'); // low confidence
258
225
 
259
- const labelText = label ? chalk.dim(label) : '';
226
+ const labelText = label ? chalk.dim(` ${label}`) : '';
260
227
 
261
- // Show hex and RGB side by side for easy copying
262
- console.log(chalk.dim(`│ ${branch}`) + ' ' + `${conf} ${colorBlock} ${hex.padEnd(9)} ${rgb.padEnd(22)} ${labelText}`);
228
+ // First line: color swatch, hex, and label
229
+ console.log(chalk.dim(`│ ${branch}`) + ' ' + `${conf} ${colorBlock} ${hex}${labelText}`);
230
+ // Second line: RGB and LCH
231
+ console.log(chalk.dim(`│ ${indent}├─`) + ' ' + chalk.dim('rgb: ') + rgb);
232
+ console.log(chalk.dim(`│ ${indent}├─`) + ' ' + chalk.dim('lch: ') + lch);
233
+ console.log(chalk.dim(`│ ${indent}└─`) + ' ' + chalk.dim('oklch: ') + oklch);
263
234
  } catch {
264
- console.log(chalk.dim(`│ ${branch}`) + ' ' + `${hex.padEnd(9)} ${rgb.padEnd(22)} ${label ? chalk.dim(label) : ''}`);
235
+ console.log(chalk.dim(`│ ${branch}`) + ' ' + `${hex} ${label ? chalk.dim(label) : ''}`);
236
+ console.log(chalk.dim(`│ ${indent}├─`) + ' ' + chalk.dim('rgb: ') + rgb);
237
+ console.log(chalk.dim(`│ ${indent}├─`) + ' ' + chalk.dim('lch: ') + lch);
238
+ console.log(chalk.dim(`│ ${indent}└─`) + ' ' + chalk.dim('oklch: ') + oklch);
265
239
  }
266
240
  });
267
241
 
@@ -341,33 +315,49 @@ function displayTypography(typography) {
341
315
  console.log(chalk.dim(`│ ${indent}${contextBranch}`) + ' ' + chalk.hex('#8BE9FD')(context));
342
316
 
343
317
  styles.forEach((style, styleIndex) => {
344
- const modifiers = [];
318
+ const isStyleLast = styleIndex === styles.length - 1;
319
+ const styleIndent = isFontLast ? ' ' : '│ ';
320
+ const contextIndent = isContextLast ? ' ' : '│ ';
321
+ const styleBranch = isStyleLast ? '└─' : '├─';
322
+ const propIndent = isStyleLast ? ' ' : '│ ';
323
+
324
+ // Main size line
325
+ console.log(chalk.dim(`│ ${styleIndent}${contextIndent}${styleBranch}`) + ' ' + `${style.size}`);
345
326
 
327
+ // Collect properties
328
+ const props = [];
346
329
  if (style.weight && style.weight !== 400) {
347
- modifiers.push(`w${style.weight}`);
330
+ props.push({ key: 'weight', value: style.weight });
348
331
  }
349
-
350
332
  if (style.lineHeight) {
351
333
  const lh = parseFloat(style.lineHeight);
352
334
  let lhLabel = '';
353
- if (lh <= 1.3) lhLabel = 'tight';
354
- else if (lh >= 1.6) lhLabel = 'relaxed';
355
- modifiers.push(lhLabel ? `lh${style.lineHeight}(${lhLabel})` : `lh${style.lineHeight}`);
335
+ if (lh <= 1.3) lhLabel = ' (tight)';
336
+ else if (lh >= 1.6) lhLabel = ' (relaxed)';
337
+ props.push({ key: 'line-height', value: `${style.lineHeight}${lhLabel}` });
338
+ }
339
+ if (style.transform) {
340
+ props.push({ key: 'transform', value: style.transform });
341
+ }
342
+ if (style.spacing) {
343
+ props.push({ key: 'letter-spacing', value: style.spacing });
344
+ }
345
+ if (style.isFluid) {
346
+ props.push({ key: 'fluid', value: 'yes' });
347
+ }
348
+ if (style.fontFeatures) {
349
+ props.push({ key: 'features', value: style.fontFeatures });
356
350
  }
357
351
 
358
- if (style.transform) modifiers.push(style.transform);
359
- if (style.spacing) modifiers.push(`ls${style.spacing}`);
360
- if (style.isFluid) modifiers.push('fluid');
361
- if (style.fontFeatures) modifiers.push('features');
362
-
363
- const modifierStr = modifiers.length > 0 ? ` ${chalk.dim('[' + modifiers.join(' ') + ']')}` : '';
364
-
365
- const isStyleLast = styleIndex === styles.length - 1;
366
- const styleIndent = isFontLast ? ' ' : '│ ';
367
- const contextIndent = isContextLast ? ' ' : '│ ';
368
- const styleBranch = isStyleLast ? '└─' : '├─';
369
-
370
- console.log(chalk.dim(`│ ${styleIndent}${contextIndent}${styleBranch}`) + ' ' + `${style.size}${modifierStr}`);
352
+ // Display properties
353
+ props.forEach((prop, propIndex) => {
354
+ const isLastProp = propIndex === props.length - 1;
355
+ const propBranch = isLastProp ? '└─' : '├─';
356
+ console.log(
357
+ chalk.dim(`│ ${styleIndent}${contextIndent}${propIndent}${propBranch}`) + ' ' +
358
+ chalk.dim(`${prop.key}: `) + `${prop.value}`
359
+ );
360
+ });
371
361
  });
372
362
  }
373
363
  }