designlang 7.1.0 → 7.2.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.
@@ -0,0 +1,120 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { reconcileRoutes, slugForPath, formatRoutesReport } from '../src/formatters/routes-reconciliation.js';
4
+
5
+ function leaf(v) { return { $value: v, $type: 'color' }; }
6
+
7
+ const baseTokens = {
8
+ primitive: {
9
+ color: {
10
+ brand: { primary: leaf('#ff0000'), secondary: leaf('#00ff00') },
11
+ neutral: { 100: leaf('#ffffff') },
12
+ },
13
+ },
14
+ semantic: {},
15
+ };
16
+
17
+ describe('slugForPath', () => {
18
+ it('treats / and empty as index', () => {
19
+ assert.equal(slugForPath('/'), 'index');
20
+ assert.equal(slugForPath(''), 'index');
21
+ assert.equal(slugForPath(null), 'index');
22
+ });
23
+ it('slugifies standard paths', () => {
24
+ assert.equal(slugForPath('/pricing'), 'pricing');
25
+ assert.equal(slugForPath('/docs/getting-started'), 'docs-getting-started');
26
+ });
27
+ });
28
+
29
+ describe('reconcileRoutes', () => {
30
+ it('returns empty-safe output for no routes', () => {
31
+ const r = reconcileRoutes([]);
32
+ assert.equal(r.summary.routeCount, 0);
33
+ assert.equal(r.summary.sharedTokenCount, 0);
34
+ assert.deepEqual(r.perRoute, {});
35
+ });
36
+
37
+ it('intersects identical tokens across routes into shared', () => {
38
+ const routes = [
39
+ { url: 'https://x.com/', path: '/', tokens: baseTokens },
40
+ { url: 'https://x.com/about', path: '/about', tokens: baseTokens },
41
+ ];
42
+ const r = reconcileRoutes(routes);
43
+ assert.equal(r.summary.routeCount, 2);
44
+ assert.ok(r.summary.sharedTokenCount >= 3);
45
+ // shared tree has the primitive.color.brand.primary leaf
46
+ assert.equal(r.shared.primitive.color.brand.primary.$value, '#ff0000');
47
+ // neither route should have "added" or "changed" entries
48
+ assert.deepEqual(r.perRoute.index.added, {});
49
+ assert.deepEqual(r.perRoute.index.changed, {});
50
+ assert.deepEqual(r.perRoute.about.added, {});
51
+ });
52
+
53
+ it('emits `added` tokens for a route that has unique tokens', () => {
54
+ const priceTokens = {
55
+ primitive: {
56
+ color: {
57
+ brand: { primary: leaf('#ff0000'), secondary: leaf('#00ff00') },
58
+ neutral: { 100: leaf('#ffffff') },
59
+ accent: { gold: leaf('#ffd700') }, // unique to /pricing
60
+ },
61
+ },
62
+ semantic: {},
63
+ };
64
+ const routes = [
65
+ { url: 'https://x.com/', path: '/', tokens: baseTokens },
66
+ { url: 'https://x.com/pricing', path: '/pricing', tokens: priceTokens },
67
+ ];
68
+ const r = reconcileRoutes(routes);
69
+ assert.equal(r.perRoute.pricing.added.primitive.color.accent.gold.$value, '#ffd700');
70
+ // The base route has no "added" since all its tokens are shared or absent.
71
+ assert.deepEqual(r.perRoute.index.added, {});
72
+ });
73
+
74
+ it('emits `changed` when a route overrides an otherwise-shared token value', () => {
75
+ const darkTokens = {
76
+ primitive: {
77
+ color: {
78
+ brand: { primary: leaf('#aa0000'), secondary: leaf('#00ff00') }, // primary differs
79
+ neutral: { 100: leaf('#ffffff') },
80
+ },
81
+ },
82
+ semantic: {},
83
+ };
84
+ const routes = [
85
+ { url: 'https://x.com/', path: '/', tokens: baseTokens },
86
+ { url: 'https://x.com/dark', path: '/dark', tokens: darkTokens },
87
+ ];
88
+ const r = reconcileRoutes(routes);
89
+ // primary shouldn't be shared because values disagree
90
+ assert.ok(!('primary' in (r.shared.primitive?.color?.brand || {})),
91
+ 'disagreeing primary should not be shared');
92
+ // both routes should have primary in their `changed` bucket
93
+ assert.equal(r.perRoute.index.changed.primitive.color.brand.primary.$value, '#ff0000');
94
+ assert.equal(r.perRoute.dark.changed.primitive.color.brand.primary.$value, '#aa0000');
95
+ assert.ok(r.summary.drift.length >= 2);
96
+ });
97
+
98
+ it('resolves slug collisions by appending a numeric suffix', () => {
99
+ const routes = [
100
+ { url: 'https://x.com/docs/a', path: '/docs-a', tokens: baseTokens },
101
+ { url: 'https://x.com/docs-a/', path: '/docs-a', tokens: baseTokens },
102
+ ];
103
+ const r = reconcileRoutes(routes);
104
+ const slugs = Object.keys(r.perRoute);
105
+ assert.equal(slugs.length, 2);
106
+ assert.ok(slugs.includes('docs-a'));
107
+ assert.ok(slugs.some(s => s.startsWith('docs-a-')));
108
+ });
109
+
110
+ it('formatRoutesReport produces readable markdown', () => {
111
+ const routes = [
112
+ { url: 'https://x.com/', path: '/', tokens: baseTokens },
113
+ { url: 'https://x.com/pricing', path: '/pricing', tokens: baseTokens },
114
+ ];
115
+ const md = formatRoutesReport(reconcileRoutes(routes));
116
+ assert.match(md, /Routes crawled.*2/);
117
+ assert.match(md, /Shared tokens/);
118
+ assert.match(md, /pricing/);
119
+ });
120
+ });
@@ -0,0 +1,90 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { oklchToSrgb, oklabToSrgb, oklchLikeToHex, parseOklchOrOklab, rgbToHex } from '../src/utils/color-gamut.js';
4
+ import { extractWideGamut } from '../src/extractors/wide-gamut.js';
5
+ import { extractTokenSources } from '../src/extractors/token-sources.js';
6
+
7
+ describe('color-gamut', () => {
8
+ it('converts oklch(1 0 0) to white', () => {
9
+ const hex = oklchLikeToHex('oklch(1 0 0)');
10
+ assert.equal(hex, '#ffffff');
11
+ });
12
+
13
+ it('converts oklch(0 0 0) to black', () => {
14
+ const hex = oklchLikeToHex('oklch(0 0 0)');
15
+ assert.equal(hex, '#000000');
16
+ });
17
+
18
+ it('converts a mid-chroma oklch to approximate sRGB', () => {
19
+ // oklch(62.8% 0.258 29.23) ≈ red (#ff0000) region
20
+ const hex = oklchLikeToHex('oklch(62.8% 0.258 29.23)');
21
+ assert.ok(/^#[0-9a-f]{6}$/.test(hex));
22
+ // Red channel should dominate
23
+ const r = parseInt(hex.slice(1, 3), 16);
24
+ const g = parseInt(hex.slice(3, 5), 16);
25
+ const b = parseInt(hex.slice(5, 7), 16);
26
+ assert.ok(r > g, `expected r>g, got ${hex}`);
27
+ assert.ok(r > b, `expected r>b, got ${hex}`);
28
+ });
29
+
30
+ it('parses oklab values with percentages', () => {
31
+ const p = parseOklchOrOklab('oklab(62.8% 0.1 -0.1)');
32
+ assert.equal(p.type, 'oklab');
33
+ assert.ok(Math.abs(p.L - 0.628) < 1e-6);
34
+ });
35
+
36
+ it('rgbToHex clamps and formats correctly', () => {
37
+ assert.equal(rgbToHex(1, 0, 0), '#ff0000');
38
+ assert.equal(rgbToHex(0, 1, 0), '#00ff00');
39
+ assert.equal(rgbToHex(1.5, -0.5, 0.5), '#ff0080');
40
+ });
41
+ });
42
+
43
+ describe('extractWideGamut', () => {
44
+ it('returns zeros for empty input', () => {
45
+ const r = extractWideGamut([]);
46
+ assert.equal(r.totalCount, 0);
47
+ assert.equal(r.oklch.count, 0);
48
+ });
49
+
50
+ it('buckets colors by type and emits hex for oklch samples', () => {
51
+ const r = extractWideGamut([
52
+ { raw: 'oklch(62.8% 0.258 29.23)', type: 'oklch', property: 'color', selector: '.btn' },
53
+ { raw: 'color-mix(in oklab, red, blue)', type: 'color-mix', property: 'background', selector: '.x' },
54
+ { raw: 'light-dark(white, black)', type: 'light-dark', property: 'color', selector: '.y' },
55
+ { raw: 'color(display-p3 1 0 0)', type: 'display-p3', property: 'background', selector: '.p3' },
56
+ ]);
57
+ assert.equal(r.oklch.count, 1);
58
+ assert.ok(r.oklch.samples[0].value?.startsWith('#'));
59
+ assert.equal(r.colorMix.count, 1);
60
+ assert.equal(r.lightDark.count, 1);
61
+ assert.equal(r.displayP3.count, 1);
62
+ assert.equal(r.totalCount, 4);
63
+ });
64
+ });
65
+
66
+ describe('extractTokenSources', () => {
67
+ it('returns an array of token→sourceUrl entries based on first matching element', () => {
68
+ const design = {
69
+ colors: { primary: { hex: '#0072f5' }, text: ['#111111'] },
70
+ typography: { families: [{ name: 'Geist' }] },
71
+ spacing: { base: 8 },
72
+ borders: { radii: [{ value: '8px' }] },
73
+ };
74
+ const styles = [
75
+ { color: 'rgb(0, 114, 245)', fontFamily: '"Geist", sans-serif', paddingTop: '8px', borderRadius: '8px', sources: [{ url: 'https://cdn.example/app.css', mediaText: '' }] },
76
+ { color: 'rgb(17, 17, 17)', sources: [{ url: 'https://cdn.example/text.css', mediaText: '' }] },
77
+ ];
78
+ const out = extractTokenSources(design, styles);
79
+ const primary = out.find(t => t.token === 'color.primary');
80
+ assert.equal(primary.sourceUrl, 'https://cdn.example/app.css');
81
+ const text = out.find(t => t.token === 'color.text');
82
+ assert.equal(text.sourceUrl, 'https://cdn.example/text.css');
83
+ const font = out.find(t => t.token === 'font.body');
84
+ assert.equal(font.sourceUrl, 'https://cdn.example/app.css');
85
+ const spacing = out.find(t => t.token === 'spacing.base');
86
+ assert.equal(spacing.sourceUrl, 'https://cdn.example/app.css');
87
+ const radius = out.find(t => t.token === 'radius.base');
88
+ assert.equal(radius.sourceUrl, 'https://cdn.example/app.css');
89
+ });
90
+ });
@@ -187,8 +187,10 @@ function InstallTracks() {
187
187
  {`1 $ npx designlang <url>
188
188
  2 $ npx designlang <url> --platforms all
189
189
  3 $ npx designlang <url> --emit-agent-rules
190
- 4 $ npx designlang <url> --tokens-legacy
191
- 5 $ npx designlang <url> --dark`}
190
+ 4 $ npx designlang <url> --cookie-file ./cookies.txt
191
+ 5 $ npx designlang <url> --insecure
192
+ 6 $ npx designlang <url> --user-agent "custom-ua"
193
+ 7 $ npx designlang <url> --tokens-legacy`}
192
194
  </pre>
193
195
  </div>
194
196
 
@@ -246,26 +248,71 @@ function InstallTracks() {
246
248
  <li>agents.md</li>
247
249
  </ul>
248
250
  </div>
251
+
252
+ {/* Chrome extension */}
253
+ <div className="install-col">
254
+ <div style={colHead}>track 04 · new in v7.1</div>
255
+ <h3 className="display" style={colTitle}>Chrome extension</h3>
256
+ <p style={{ fontSize: 14, color: 'var(--ink-2)', marginBottom: 'var(--r3)', maxWidth: '34ch' }}>
257
+ One click from any tab. Opens this page with the URL prefilled.
258
+ </p>
259
+ <ol
260
+ className="mono"
261
+ style={{
262
+ padding: 0,
263
+ margin: 0,
264
+ listStylePosition: 'inside',
265
+ fontSize: 12,
266
+ lineHeight: 1.9,
267
+ color: 'var(--ink-2)',
268
+ }}
269
+ >
270
+ <li>clone the repo</li>
271
+ <li>open <code style={{ color: 'var(--ink)' }}>chrome://extensions</code></li>
272
+ <li>toggle developer mode</li>
273
+ <li>load unpacked → <code style={{ color: 'var(--ink)' }}>chrome-extension/</code></li>
274
+ </ol>
275
+ <p style={{ fontSize: 13, color: 'var(--ink-2)', marginTop: 'var(--r4)', fontStyle: 'italic', fontFamily: 'var(--font-display)' }}>
276
+ Manifest v3. Only permission: <code className="mono" style={{ fontStyle: 'normal' }}>activeTab</code>.
277
+ </p>
278
+ <a
279
+ href="https://github.com/Manavarya09/design-extract/tree/main/chrome-extension"
280
+ target="_blank"
281
+ rel="noopener"
282
+ className="mono"
283
+ style={{ display: 'inline-block', marginTop: 'var(--r3)', fontSize: 12, letterSpacing: '0.06em', textTransform: 'uppercase' }}
284
+ >
285
+ source →
286
+ </a>
287
+ </div>
249
288
  </div>
250
289
 
251
290
  <style>{`
252
291
  .install-grid {
253
292
  display: grid;
254
- grid-template-columns: repeat(3, minmax(0, 1fr));
293
+ grid-template-columns: repeat(4, minmax(0, 1fr));
255
294
  gap: 0;
256
295
  }
257
296
  .install-col {
258
- padding: 0 var(--r5);
297
+ padding: 0 var(--r4);
259
298
  }
260
299
  .install-col:first-child { padding-left: 0; }
261
300
  .install-col:last-child { padding-right: 0; }
262
301
  .install-col + .install-col {
263
302
  border-left: 1px solid var(--ink);
264
303
  }
265
- @media (max-width: 860px) {
266
- .install-grid { grid-template-columns: 1fr; gap: var(--r6); }
267
- .install-col { padding: 0; }
268
- .install-col + .install-col { border-left: 0; border-top: 1px solid var(--ink); padding-top: var(--r5); }
304
+ @media (max-width: 1100px) {
305
+ .install-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0; }
306
+ .install-col { padding: var(--r4) var(--r4); }
307
+ .install-col:nth-child(2n+1) { padding-left: 0; }
308
+ .install-col:nth-child(2n) { padding-right: 0; }
309
+ .install-col:nth-child(n+3) { border-top: 1px solid var(--ink); }
310
+ .install-col:nth-child(2n+1) { border-left: 0; }
311
+ }
312
+ @media (max-width: 640px) {
313
+ .install-grid { grid-template-columns: 1fr; gap: 0; }
314
+ .install-col { padding: var(--r5) 0; border-left: 0 !important; }
315
+ .install-col + .install-col { border-top: 1px solid var(--ink); }
269
316
  }
270
317
  `}</style>
271
318
  </div>