designlang 7.0.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.
- package/.github/og-preview.png +0 -0
- package/.github/workflows/manavarya-bot.yml +17 -0
- package/.vercel/README.txt +11 -0
- package/.vercel/project.json +1 -0
- package/CHANGELOG.md +29 -0
- package/CONTRIBUTING.md +25 -0
- package/README.md +38 -11
- package/bin/design-extract.js +41 -2
- package/chrome-extension/README.md +41 -0
- package/chrome-extension/icons/favicon.svg +7 -0
- package/chrome-extension/icons/icon-128.png +0 -0
- package/chrome-extension/icons/icon-16.png +0 -0
- package/chrome-extension/icons/icon-32.png +0 -0
- package/chrome-extension/icons/icon-48.png +0 -0
- package/chrome-extension/manifest.json +26 -0
- package/chrome-extension/popup.html +167 -0
- package/chrome-extension/popup.js +59 -0
- package/docs/superpowers/specs/2026-04-18-website-redesign-design.md +120 -0
- package/docs/superpowers/specs/2026-04-19-designlang-v7-1-design.md +111 -0
- package/package.json +1 -1
- package/src/config.js +5 -1
- package/src/crawler.js +361 -2
- package/src/extractors/interaction-states.js +57 -0
- package/src/extractors/modern-css.js +100 -0
- package/src/extractors/token-sources.js +65 -0
- package/src/extractors/wide-gamut.js +47 -0
- package/src/formatters/routes-reconciliation.js +160 -0
- package/src/index.js +29 -0
- package/src/utils/color-gamut.js +82 -0
- package/src/utils-cookies.js +73 -0
- package/tests/cookies.test.js +98 -0
- package/tests/interaction-states.test.js +62 -0
- package/tests/modern-css.test.js +104 -0
- package/tests/routes-reconciliation.test.js +120 -0
- package/tests/wide-gamut.test.js +90 -0
- package/website/app/api/extract/route.js +216 -56
- package/website/app/components/A11ySlider.js +369 -0
- package/website/app/components/Comparison.js +286 -0
- package/website/app/components/CssHealth.js +243 -0
- package/website/app/components/HeroExtractor.js +455 -0
- package/website/app/components/Marginalia.js +3 -0
- package/website/app/components/McpSection.js +223 -0
- package/website/app/components/PlatformTabs.js +250 -0
- package/website/app/components/RegionsComponents.js +429 -0
- package/website/app/components/Rule.js +13 -0
- package/website/app/components/Specimens.js +237 -0
- package/website/app/components/StructuredData.js +144 -0
- package/website/app/components/TokenBrowser.js +344 -0
- package/website/app/components/token-browser-sample.js +65 -0
- package/website/app/globals.css +415 -633
- package/website/app/icon.svg +7 -0
- package/website/app/layout.js +113 -6
- package/website/app/opengraph-image.js +170 -0
- package/website/app/page.js +372 -148
- package/website/app/robots.js +15 -0
- package/website/app/seo-config.js +82 -0
- package/website/app/sitemap.js +18 -0
- package/website/lib/cache.js +73 -0
- package/website/lib/rate-limit.js +30 -0
- package/website/lib/rate-limit.test.js +55 -0
- package/website/lib/specimens.json +86 -0
- package/website/lib/token-helpers.js +70 -0
- package/website/lib/url-safety.js +103 -0
- package/website/lib/url-safety.test.js +116 -0
- package/website/lib/zip-files.js +15 -0
- package/website/package-lock.json +85 -0
- package/website/package.json +1 -0
- package/website/public/favicon.svg +7 -0
- package/website/public/logo-specimen.svg +76 -0
- package/website/public/mark.svg +12 -0
- package/website/public/site.webmanifest +13 -0
- package/website/app/favicon.ico +0 -0
- package/website/public/file.svg +0 -1
- package/website/public/globe.svg +0 -1
- package/website/public/next.svg +0 -1
- package/website/public/vercel.svg +0 -1
- package/website/public/window.svg +0 -1
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Catalog of wide-gamut / modern color function usages from the crawler's
|
|
2
|
+
// scan of stylesheet cssText. Produces counts + sample raw values + converted
|
|
3
|
+
// sRGB hex for oklch/oklab values.
|
|
4
|
+
|
|
5
|
+
import { oklchLikeToHex } from '../utils/color-gamut.js';
|
|
6
|
+
|
|
7
|
+
export function extractWideGamut(modernColors) {
|
|
8
|
+
const src = Array.isArray(modernColors) ? modernColors : [];
|
|
9
|
+
const catalog = {
|
|
10
|
+
oklch: { count: 0, samples: [] },
|
|
11
|
+
oklab: { count: 0, samples: [] },
|
|
12
|
+
colorMix: { count: 0, samples: [] },
|
|
13
|
+
lightDark: { count: 0, samples: [] },
|
|
14
|
+
displayP3: { count: 0, samples: [] },
|
|
15
|
+
rec2020: { count: 0, samples: [] },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const bucket = {
|
|
19
|
+
oklch: catalog.oklch,
|
|
20
|
+
oklab: catalog.oklab,
|
|
21
|
+
'color-mix': catalog.colorMix,
|
|
22
|
+
'light-dark': catalog.lightDark,
|
|
23
|
+
'display-p3': catalog.displayP3,
|
|
24
|
+
rec2020: catalog.rec2020,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
for (const entry of src) {
|
|
28
|
+
const b = bucket[entry.type];
|
|
29
|
+
if (!b) continue;
|
|
30
|
+
b.count++;
|
|
31
|
+
if (b.samples.length < 10) {
|
|
32
|
+
const sample = {
|
|
33
|
+
raw: entry.raw,
|
|
34
|
+
property: entry.property || '',
|
|
35
|
+
selector: entry.selector || '',
|
|
36
|
+
};
|
|
37
|
+
if (entry.type === 'oklch' || entry.type === 'oklab') {
|
|
38
|
+
const hex = oklchLikeToHex(entry.raw);
|
|
39
|
+
if (hex) sample.value = hex;
|
|
40
|
+
}
|
|
41
|
+
b.samples.push(sample);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const totalCount = Object.values(catalog).reduce((n, c) => n + c.count, 0);
|
|
46
|
+
return { ...catalog, totalCount };
|
|
47
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Shared-vs-per-route reconciliation for multi-page token extraction.
|
|
2
|
+
// Input: [{ url, path, tokens: DTCG-shaped { primitive, semantic, ... } }]
|
|
3
|
+
// Output: { shared, perRoute: { <slug>: { added, changed } }, summary }
|
|
4
|
+
//
|
|
5
|
+
// "Shared" = a token path present with the same $value in ALL routes.
|
|
6
|
+
// "Added" = a token path present on this route but missing from the shared set.
|
|
7
|
+
// "Changed"= a token path present in shared but with a different $value here.
|
|
8
|
+
|
|
9
|
+
export function slugForPath(p) {
|
|
10
|
+
if (!p || p === '/' || p === '') return 'index';
|
|
11
|
+
return String(p)
|
|
12
|
+
.replace(/^\/+|\/+$/g, '')
|
|
13
|
+
.replace(/[^a-z0-9]+/gi, '-')
|
|
14
|
+
.replace(/^-+|-+$/g, '')
|
|
15
|
+
.toLowerCase() || 'index';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isLeaf(node) {
|
|
19
|
+
return node && typeof node === 'object' && '$value' in node;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Walk a DTCG tree and produce a flat map of dotted-path -> serialized $value.
|
|
23
|
+
function flattenTokens(tree, prefix = '', out = {}) {
|
|
24
|
+
if (!tree || typeof tree !== 'object') return out;
|
|
25
|
+
if (isLeaf(tree)) {
|
|
26
|
+
out[prefix] = JSON.stringify(tree.$value);
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
for (const [k, v] of Object.entries(tree)) {
|
|
30
|
+
if (k.startsWith('$')) continue;
|
|
31
|
+
const p = prefix ? `${prefix}.${k}` : k;
|
|
32
|
+
if (v && typeof v === 'object') flattenTokens(v, p, out);
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Set a dotted-path key to a (JSON-parsed) value on a nested object (leaf as DTCG $value).
|
|
38
|
+
function setPath(root, dotted, jsonVal) {
|
|
39
|
+
const parts = dotted.split('.');
|
|
40
|
+
let cur = root;
|
|
41
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
42
|
+
const p = parts[i];
|
|
43
|
+
if (!cur[p] || typeof cur[p] !== 'object') cur[p] = {};
|
|
44
|
+
cur = cur[p];
|
|
45
|
+
}
|
|
46
|
+
cur[parts[parts.length - 1]] = { $value: JSON.parse(jsonVal) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function reconcileRoutes(routes) {
|
|
50
|
+
const safeRoutes = Array.isArray(routes) ? routes.filter(r => r && r.tokens) : [];
|
|
51
|
+
if (safeRoutes.length === 0) {
|
|
52
|
+
return {
|
|
53
|
+
shared: {},
|
|
54
|
+
perRoute: {},
|
|
55
|
+
summary: { routeCount: 0, sharedTokenCount: 0, totalUnique: 0, drift: [] },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Flatten each route's tokens (combining primitive + semantic trees).
|
|
60
|
+
const flat = safeRoutes.map(r => {
|
|
61
|
+
const merged = {};
|
|
62
|
+
flattenTokens(r.tokens.primitive || {}, 'primitive', merged);
|
|
63
|
+
flattenTokens(r.tokens.semantic || {}, 'semantic', merged);
|
|
64
|
+
return { route: r, flat: merged };
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Build shared set: paths present across ALL routes with identical serialized value.
|
|
68
|
+
const firstKeys = Object.keys(flat[0].flat);
|
|
69
|
+
const sharedFlat = {};
|
|
70
|
+
for (const key of firstKeys) {
|
|
71
|
+
const v = flat[0].flat[key];
|
|
72
|
+
const allAgree = flat.every(f => f.flat[key] === v);
|
|
73
|
+
if (allAgree) sharedFlat[key] = v;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Rebuild shared as DTCG tree.
|
|
77
|
+
const shared = {};
|
|
78
|
+
for (const [k, v] of Object.entries(sharedFlat)) setPath(shared, k, v);
|
|
79
|
+
|
|
80
|
+
// Per-route deltas.
|
|
81
|
+
const perRoute = {};
|
|
82
|
+
const usedSlugs = new Map();
|
|
83
|
+
const drift = [];
|
|
84
|
+
for (const { route, flat: f } of flat) {
|
|
85
|
+
let slug = slugForPath(route.path);
|
|
86
|
+
// Slug collision resolution.
|
|
87
|
+
if (usedSlugs.has(slug)) {
|
|
88
|
+
const n = usedSlugs.get(slug) + 1;
|
|
89
|
+
usedSlugs.set(slug, n);
|
|
90
|
+
slug = `${slug}-${n}`;
|
|
91
|
+
} else {
|
|
92
|
+
usedSlugs.set(slug, 1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const added = {};
|
|
96
|
+
const changed = {};
|
|
97
|
+
for (const [k, v] of Object.entries(f)) {
|
|
98
|
+
if (!(k in sharedFlat)) {
|
|
99
|
+
// Not shared at all — either unique to this route or conflicts across routes.
|
|
100
|
+
// If the key exists in another route with a different value, it's a "changed" vs shared is not applicable;
|
|
101
|
+
// we classify as added when the token is absent from sharedFlat entirely.
|
|
102
|
+
const existsInOthers = flat.some(o => o.flat !== f && (k in o.flat));
|
|
103
|
+
if (!existsInOthers) {
|
|
104
|
+
setPath(added, k, v);
|
|
105
|
+
} else {
|
|
106
|
+
// Present in multiple routes but disagreeing values -> changed relative to the shared baseline.
|
|
107
|
+
setPath(changed, k, v);
|
|
108
|
+
drift.push({ path: route.path, token: k, value: JSON.parse(v) });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
perRoute[slug] = { url: route.url, path: route.path, added, changed };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const allKeys = new Set();
|
|
116
|
+
for (const f of flat) for (const k of Object.keys(f.flat)) allKeys.add(k);
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
shared,
|
|
120
|
+
perRoute,
|
|
121
|
+
summary: {
|
|
122
|
+
routeCount: safeRoutes.length,
|
|
123
|
+
sharedTokenCount: Object.keys(sharedFlat).length,
|
|
124
|
+
totalUnique: allKeys.size,
|
|
125
|
+
drift,
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function formatRoutesReport(reconciled) {
|
|
131
|
+
const { summary, perRoute } = reconciled;
|
|
132
|
+
const lines = [];
|
|
133
|
+
lines.push('# Multi-route Token Reconciliation');
|
|
134
|
+
lines.push('');
|
|
135
|
+
lines.push(`- Routes crawled: **${summary.routeCount}**`);
|
|
136
|
+
lines.push(`- Shared tokens: **${summary.sharedTokenCount}**`);
|
|
137
|
+
lines.push(`- Total unique tokens across routes: **${summary.totalUnique}**`);
|
|
138
|
+
lines.push(`- Cross-route drift entries: **${summary.drift.length}**`);
|
|
139
|
+
lines.push('');
|
|
140
|
+
lines.push('## Per-route contributions');
|
|
141
|
+
for (const [slug, entry] of Object.entries(perRoute)) {
|
|
142
|
+
const addedCount = countLeaves(entry.added);
|
|
143
|
+
const changedCount = countLeaves(entry.changed);
|
|
144
|
+
lines.push(`- \`${entry.path}\` (${slug}): ${addedCount} added, ${changedCount} changed`);
|
|
145
|
+
}
|
|
146
|
+
return lines.join('\n') + '\n';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function countLeaves(tree) {
|
|
150
|
+
let n = 0;
|
|
151
|
+
const walk = (node) => {
|
|
152
|
+
if (!node || typeof node !== 'object') return;
|
|
153
|
+
if (isLeaf(node)) { n++; return; }
|
|
154
|
+
for (const [k, v] of Object.entries(node)) {
|
|
155
|
+
if (!k.startsWith('$')) walk(v);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
walk(tree);
|
|
159
|
+
return n;
|
|
160
|
+
}
|
package/src/index.js
CHANGED
|
@@ -21,6 +21,11 @@ import { extractCssHealth } from './extractors/css-health.js';
|
|
|
21
21
|
import { remediateFailingPairs } from './extractors/a11y-remediation.js';
|
|
22
22
|
import { extractSemanticRegions } from './extractors/semantic-regions.js';
|
|
23
23
|
import { clusterComponents } from './extractors/component-clusters.js';
|
|
24
|
+
import { extractModernCss } from './extractors/modern-css.js';
|
|
25
|
+
import { extractWideGamut } from './extractors/wide-gamut.js';
|
|
26
|
+
import { extractTokenSources } from './extractors/token-sources.js';
|
|
27
|
+
import { extractInteractionStates } from './extractors/interaction-states.js';
|
|
28
|
+
import { formatDtcgTokens } from './formatters/dtcg-tokens.js';
|
|
24
29
|
|
|
25
30
|
function safeExtract(fn, ...args) {
|
|
26
31
|
try { return fn(...args); } catch { return null; }
|
|
@@ -30,6 +35,7 @@ export async function extractDesignLanguage(url, options = {}) {
|
|
|
30
35
|
const rawData = await crawlPage(url, {
|
|
31
36
|
...options,
|
|
32
37
|
ignore: options.ignore,
|
|
38
|
+
deepInteract: options.deepInteract,
|
|
33
39
|
});
|
|
34
40
|
const styles = rawData.light.computedStyles;
|
|
35
41
|
const warnings = [];
|
|
@@ -63,6 +69,10 @@ export async function extractDesignLanguage(url, options = {}) {
|
|
|
63
69
|
cssHealth: safeExtract(extractCssHealth, rawData.light.cssCoverage) || null,
|
|
64
70
|
regions: safeExtract(extractSemanticRegions, rawData.light.sections) || [],
|
|
65
71
|
componentClusters: safeExtract(clusterComponents, rawData.light.componentCandidates) || [],
|
|
72
|
+
modernCss: safeExtract(extractModernCss, rawData) || { pseudoElements: { count: 0, samples: [] }, variableFonts: { count: 0, axes: [] }, openTypeFeatures: [], textWrap: { wrap: [], decorationStyle: [], decorationThickness: [], underlineOffset: [] }, containerQueries: { count: 0, rules: [] }, envUsage: [] },
|
|
73
|
+
wideGamut: safeExtract(extractWideGamut, rawData.light.modernColors || []) || { oklch: { count: 0, samples: [] }, oklab: { count: 0, samples: [] }, colorMix: { count: 0, samples: [] }, lightDark: { count: 0, samples: [] }, displayP3: { count: 0, samples: [] }, rec2020: { count: 0, samples: [] }, totalCount: 0 },
|
|
74
|
+
tokenSources: [],
|
|
75
|
+
interactionStates: safeExtract(extractInteractionStates, rawData.interactState || rawData.light.interactState) || { scrollSettled: false, menusOpened: 0, hover: { sampled: 0, changed: 0, deltas: [] }, accordionsOpened: 0, modals: [] },
|
|
66
76
|
score: null,
|
|
67
77
|
};
|
|
68
78
|
|
|
@@ -106,6 +116,25 @@ export async function extractDesignLanguage(url, options = {}) {
|
|
|
106
116
|
};
|
|
107
117
|
} catch { /* non-fatal */ }
|
|
108
118
|
|
|
119
|
+
design.tokenSources = safeExtract(extractTokenSources, design, styles) || [];
|
|
120
|
+
|
|
121
|
+
// Per-route token extraction (Tier 2 multi-page reconciliation).
|
|
122
|
+
if (Array.isArray(rawData.routes) && rawData.routes.length > 0) {
|
|
123
|
+
design.routes = rawData.routes.map(r => {
|
|
124
|
+
const rStyles = r.computedStylesSample || [];
|
|
125
|
+
const rDesign = {
|
|
126
|
+
meta: { url: r.url },
|
|
127
|
+
colors: safeExtract(extractColors, rStyles) || { all: [], neutrals: [], backgrounds: [], text: [], gradients: [] },
|
|
128
|
+
typography: safeExtract(extractTypography, rStyles) || { families: [], scale: [] },
|
|
129
|
+
spacing: safeExtract(extractSpacing, rStyles) || { scale: [], base: null },
|
|
130
|
+
shadows: safeExtract(extractShadows, rStyles) || { values: [] },
|
|
131
|
+
borders: safeExtract(extractBorders, rStyles) || { radii: [] },
|
|
132
|
+
};
|
|
133
|
+
const tokens = safeExtract(formatDtcgTokens, rDesign) || { primitive: {}, semantic: {} };
|
|
134
|
+
return { url: r.url, path: r.path, tokens };
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
109
138
|
design.score = safeExtract(scoreDesignSystem, design);
|
|
110
139
|
if (design.score === null) warnings.push('scoring failed');
|
|
111
140
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// OKLCH / OKLab → sRGB hex conversion utilities.
|
|
2
|
+
// Based on the public OKLab formulas by Björn Ottosson
|
|
3
|
+
// (https://bottosson.github.io/posts/oklab/). Implemented locally (no deps).
|
|
4
|
+
|
|
5
|
+
function clamp01(v) { return Math.max(0, Math.min(1, v)); }
|
|
6
|
+
|
|
7
|
+
function linearToSrgb(x) {
|
|
8
|
+
// Convert linear-light sRGB [0..1] to sRGB gamma-encoded [0..1]
|
|
9
|
+
if (x < 0) x = 0;
|
|
10
|
+
if (x > 1) x = 1;
|
|
11
|
+
return x <= 0.0031308 ? 12.92 * x : 1.055 * Math.pow(x, 1 / 2.4) - 0.055;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function oklabToSrgb(L, a, b) {
|
|
15
|
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
16
|
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
17
|
+
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
|
18
|
+
|
|
19
|
+
const l = l_ * l_ * l_;
|
|
20
|
+
const m = m_ * m_ * m_;
|
|
21
|
+
const s = s_ * s_ * s_;
|
|
22
|
+
|
|
23
|
+
const r = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
|
|
24
|
+
const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
|
|
25
|
+
const b2 = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s;
|
|
26
|
+
|
|
27
|
+
return [linearToSrgb(r), linearToSrgb(g), linearToSrgb(b2)];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function oklchToSrgb(L, C, h) {
|
|
31
|
+
const hr = (h * Math.PI) / 180;
|
|
32
|
+
const a = C * Math.cos(hr);
|
|
33
|
+
const b = C * Math.sin(hr);
|
|
34
|
+
return oklabToSrgb(L, a, b);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function toHex(v) {
|
|
38
|
+
const n = Math.round(clamp01(v) * 255);
|
|
39
|
+
return n.toString(16).padStart(2, '0');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function rgbToHex(r, g, b) {
|
|
43
|
+
return '#' + toHex(r) + toHex(g) + toHex(b);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Parse an oklch() or oklab() CSS value. Accepts values like:
|
|
47
|
+
// oklch(62.8% 0.258 29.23)
|
|
48
|
+
// oklch(0.628 0.258 29.23)
|
|
49
|
+
// oklab(0.628 0.1 -0.1)
|
|
50
|
+
// Returns { type: 'oklch'|'oklab', L, C, h, a, b, raw } or null.
|
|
51
|
+
export function parseOklchOrOklab(raw) {
|
|
52
|
+
if (!raw || typeof raw !== 'string') return null;
|
|
53
|
+
const m = raw.match(/^\s*(oklch|oklab)\(\s*([^)]+)\)\s*$/i);
|
|
54
|
+
if (!m) return null;
|
|
55
|
+
const type = m[1].toLowerCase();
|
|
56
|
+
// Strip alpha (everything after /)
|
|
57
|
+
const body = m[2].split('/')[0].trim();
|
|
58
|
+
const parts = body.split(/[\s,]+/).filter(Boolean);
|
|
59
|
+
if (parts.length < 3) return null;
|
|
60
|
+
|
|
61
|
+
function parseNum(s, scale = 1) {
|
|
62
|
+
if (s.endsWith('%')) return parseFloat(s) / 100;
|
|
63
|
+
return parseFloat(s) * scale;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const p0 = parts[0].endsWith('%') ? parseFloat(parts[0]) / 100 : parseFloat(parts[0]);
|
|
67
|
+
const p1 = parseFloat(parts[1]);
|
|
68
|
+
const p2 = parseFloat(parts[2]);
|
|
69
|
+
if ([p0, p1, p2].some(v => Number.isNaN(v))) return null;
|
|
70
|
+
|
|
71
|
+
if (type === 'oklch') return { type, L: p0, C: p1, h: p2, raw };
|
|
72
|
+
return { type, L: p0, a: p1, b: p2, raw };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function oklchLikeToHex(raw) {
|
|
76
|
+
const parsed = parseOklchOrOklab(raw);
|
|
77
|
+
if (!parsed) return null;
|
|
78
|
+
const [r, g, b] = parsed.type === 'oklch'
|
|
79
|
+
? oklchToSrgb(parsed.L, parsed.C, parsed.h)
|
|
80
|
+
: oklabToSrgb(parsed.L, parsed.a, parsed.b);
|
|
81
|
+
return rgbToHex(r, g, b);
|
|
82
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Cookie file loaders. Supports three formats so users can paste whatever
|
|
2
|
+
// their existing tooling exports:
|
|
3
|
+
// - JSON array of Playwright cookie objects: [{name, value, domain, path, …}]
|
|
4
|
+
// - Playwright storageState JSON: { cookies: [...], origins: [...] }
|
|
5
|
+
// - Netscape cookies.txt: tab-separated lines (curl / wget / browser extensions)
|
|
6
|
+
//
|
|
7
|
+
// Returned shape is always the Playwright cookie array.
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from 'fs';
|
|
10
|
+
|
|
11
|
+
function parseNetscape(text, targetUrl) {
|
|
12
|
+
const cookies = [];
|
|
13
|
+
const lines = text.split(/\r?\n/);
|
|
14
|
+
for (const raw of lines) {
|
|
15
|
+
const line = raw.trim();
|
|
16
|
+
if (!line) continue;
|
|
17
|
+
// Skip comment lines, but keep the Netscape `#HttpOnly_<domain>` prefix
|
|
18
|
+
// that browsers use to mark HttpOnly cookies — those are real entries.
|
|
19
|
+
if (line.startsWith('#') && !/^#HttpOnly_/i.test(line)) continue;
|
|
20
|
+
const parts = raw.split('\t');
|
|
21
|
+
if (parts.length < 7) continue;
|
|
22
|
+
const [domain, , path, secure, expires, name, value] = parts;
|
|
23
|
+
if (!name) continue;
|
|
24
|
+
const cookie = {
|
|
25
|
+
name,
|
|
26
|
+
value: value ?? '',
|
|
27
|
+
domain: domain.replace(/^#HttpOnly_/i, ''),
|
|
28
|
+
path: path || '/',
|
|
29
|
+
secure: secure === 'TRUE',
|
|
30
|
+
httpOnly: /^#HttpOnly_/i.test(domain),
|
|
31
|
+
};
|
|
32
|
+
const exp = Number(expires);
|
|
33
|
+
if (Number.isFinite(exp) && exp > 0) cookie.expires = exp;
|
|
34
|
+
if (!cookie.domain && targetUrl) cookie.url = targetUrl;
|
|
35
|
+
cookies.push(cookie);
|
|
36
|
+
}
|
|
37
|
+
return cookies;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseJson(text) {
|
|
41
|
+
const parsed = JSON.parse(text);
|
|
42
|
+
// Playwright storageState: { cookies: [...], origins: [...] }
|
|
43
|
+
if (parsed && Array.isArray(parsed.cookies)) return parsed.cookies;
|
|
44
|
+
// Raw array
|
|
45
|
+
if (Array.isArray(parsed)) return parsed;
|
|
46
|
+
throw new Error('cookie file: JSON must be a cookie array or Playwright storageState');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function loadCookiesFromFile(filePath, targetUrl) {
|
|
50
|
+
const text = readFileSync(filePath, 'utf-8');
|
|
51
|
+
const trimmed = text.trimStart();
|
|
52
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
53
|
+
return parseJson(text);
|
|
54
|
+
}
|
|
55
|
+
return parseNetscape(text, targetUrl);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Merge CLI-provided cookies (name=value strings) with file cookies.
|
|
59
|
+
// Later entries (file) override earlier entries (CLI) at the same name+domain.
|
|
60
|
+
export function mergeCookies(cliCookies = [], fileCookies = [], targetUrl) {
|
|
61
|
+
const seen = new Map();
|
|
62
|
+
const parseCli = (c) => {
|
|
63
|
+
if (typeof c !== 'string') return c;
|
|
64
|
+
const [name, ...rest] = c.split('=');
|
|
65
|
+
return { name, value: rest.join('='), url: targetUrl };
|
|
66
|
+
};
|
|
67
|
+
for (const c of [...cliCookies.map(parseCli), ...fileCookies]) {
|
|
68
|
+
if (!c || !c.name) continue;
|
|
69
|
+
const key = `${c.name}|${c.domain || c.url || ''}`;
|
|
70
|
+
seen.set(key, c);
|
|
71
|
+
}
|
|
72
|
+
return [...seen.values()];
|
|
73
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { writeFileSync, mkdtempSync } from 'fs';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { loadCookiesFromFile, mergeCookies } from '../src/utils-cookies.js';
|
|
7
|
+
|
|
8
|
+
const tmp = mkdtempSync(join(tmpdir(), 'dl-cookies-'));
|
|
9
|
+
|
|
10
|
+
function tmpFile(name, text) {
|
|
11
|
+
const p = join(tmp, name);
|
|
12
|
+
writeFileSync(p, text, 'utf-8');
|
|
13
|
+
return p;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('loadCookiesFromFile', () => {
|
|
17
|
+
it('loads JSON array', () => {
|
|
18
|
+
const f = tmpFile('c.json', JSON.stringify([
|
|
19
|
+
{ name: 'session', value: 'abc', domain: '.example.com', path: '/' },
|
|
20
|
+
]));
|
|
21
|
+
const out = loadCookiesFromFile(f, 'https://example.com');
|
|
22
|
+
assert.equal(out.length, 1);
|
|
23
|
+
assert.equal(out[0].name, 'session');
|
|
24
|
+
assert.equal(out[0].domain, '.example.com');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('loads Playwright storageState', () => {
|
|
28
|
+
const f = tmpFile('state.json', JSON.stringify({
|
|
29
|
+
cookies: [{ name: 'csrf', value: 'xyz', domain: 'example.com', path: '/' }],
|
|
30
|
+
origins: [],
|
|
31
|
+
}));
|
|
32
|
+
const out = loadCookiesFromFile(f, 'https://example.com');
|
|
33
|
+
assert.equal(out.length, 1);
|
|
34
|
+
assert.equal(out[0].name, 'csrf');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('loads Netscape cookies.txt with tab-separated lines and comment', () => {
|
|
38
|
+
const netscape = [
|
|
39
|
+
'# Netscape HTTP Cookie File',
|
|
40
|
+
'# comment',
|
|
41
|
+
'.example.com\tTRUE\t/\tFALSE\t1765000000\tsid\tabc123',
|
|
42
|
+
'#HttpOnly_.example.com\tTRUE\t/\tTRUE\t0\tauth\ttoken-value',
|
|
43
|
+
].join('\n');
|
|
44
|
+
const f = tmpFile('c.txt', netscape);
|
|
45
|
+
const out = loadCookiesFromFile(f, 'https://example.com');
|
|
46
|
+
assert.equal(out.length, 2);
|
|
47
|
+
const sid = out.find((c) => c.name === 'sid');
|
|
48
|
+
assert.equal(sid.value, 'abc123');
|
|
49
|
+
assert.equal(sid.domain, '.example.com');
|
|
50
|
+
assert.equal(sid.secure, false);
|
|
51
|
+
assert.equal(sid.expires, 1765000000);
|
|
52
|
+
const auth = out.find((c) => c.name === 'auth');
|
|
53
|
+
assert.equal(auth.httpOnly, true);
|
|
54
|
+
assert.equal(auth.secure, true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('throws on invalid JSON shape', () => {
|
|
58
|
+
const f = tmpFile('bad.json', '{"not":"array"}');
|
|
59
|
+
assert.throws(() => loadCookiesFromFile(f, 'https://example.com'));
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('mergeCookies', () => {
|
|
64
|
+
it('parses CLI name=value strings into cookie objects', () => {
|
|
65
|
+
const out = mergeCookies(['session=abc'], [], 'https://example.com');
|
|
66
|
+
assert.equal(out.length, 1);
|
|
67
|
+
assert.equal(out[0].name, 'session');
|
|
68
|
+
assert.equal(out[0].value, 'abc');
|
|
69
|
+
assert.equal(out[0].url, 'https://example.com');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('lets file cookies override CLI cookies with the same name+domain', () => {
|
|
73
|
+
const out = mergeCookies(
|
|
74
|
+
[{ name: 'session', value: 'cli', domain: '.example.com' }],
|
|
75
|
+
[{ name: 'session', value: 'file', domain: '.example.com' }],
|
|
76
|
+
'https://example.com',
|
|
77
|
+
);
|
|
78
|
+
assert.equal(out.length, 1);
|
|
79
|
+
assert.equal(out[0].value, 'file');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('keeps cookies with different domains separately', () => {
|
|
83
|
+
const out = mergeCookies(
|
|
84
|
+
[],
|
|
85
|
+
[
|
|
86
|
+
{ name: 'x', value: '1', domain: 'a.example.com' },
|
|
87
|
+
{ name: 'x', value: '2', domain: 'b.example.com' },
|
|
88
|
+
],
|
|
89
|
+
'https://example.com',
|
|
90
|
+
);
|
|
91
|
+
assert.equal(out.length, 2);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('strips entries without a name', () => {
|
|
95
|
+
const out = mergeCookies([], [{ value: 'orphan' }], 'https://example.com');
|
|
96
|
+
assert.equal(out.length, 0);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { extractInteractionStates } from '../src/extractors/interaction-states.js';
|
|
4
|
+
|
|
5
|
+
describe('extractInteractionStates', () => {
|
|
6
|
+
it('returns a default shape on empty input', () => {
|
|
7
|
+
const r = extractInteractionStates(null);
|
|
8
|
+
assert.equal(r.scrollSettled, false);
|
|
9
|
+
assert.equal(r.menusOpened, 0);
|
|
10
|
+
assert.deepEqual(r.modals, []);
|
|
11
|
+
assert.equal(r.hover.sampled, 0);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('computes hover deltas between before/after style snapshots', () => {
|
|
15
|
+
const r = extractInteractionStates({
|
|
16
|
+
scrollSettled: true,
|
|
17
|
+
menusOpened: 2,
|
|
18
|
+
hoverSamples: [
|
|
19
|
+
{
|
|
20
|
+
selector: 'button:nth-of-type(1)',
|
|
21
|
+
before: { color: 'rgb(0,0,0)', backgroundColor: 'rgb(255,255,255)' },
|
|
22
|
+
after: { color: 'rgb(0,0,0)', backgroundColor: 'rgb(240,240,240)' },
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
selector: 'a:nth-of-type(1)',
|
|
26
|
+
before: { color: 'rgb(0,0,0)' },
|
|
27
|
+
after: { color: 'rgb(0,0,0)' },
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
accordionsOpened: 3,
|
|
31
|
+
modals: [],
|
|
32
|
+
});
|
|
33
|
+
assert.equal(r.hover.sampled, 2);
|
|
34
|
+
assert.equal(r.hover.changed, 1);
|
|
35
|
+
assert.equal(r.hover.deltas[0].changes.backgroundColor.from, 'rgb(255,255,255)');
|
|
36
|
+
assert.equal(r.hover.deltas[0].changes.backgroundColor.to, 'rgb(240,240,240)');
|
|
37
|
+
assert.equal(r.accordionsOpened, 3);
|
|
38
|
+
assert.equal(r.menusOpened, 2);
|
|
39
|
+
assert.equal(r.scrollSettled, true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('normalizes modal snapshots', () => {
|
|
43
|
+
const r = extractInteractionStates({
|
|
44
|
+
modals: [
|
|
45
|
+
{ trigger: 'Sign in', snapshot: { tag: 'dialog', role: 'dialog', bg: 'rgb(255,255,255)', color: 'rgb(17,17,17)', boxShadow: '0 10px 30px rgba(0,0,0,.2)', borderRadius: '12px', width: 400, height: 300 } },
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
assert.equal(r.modals.length, 1);
|
|
49
|
+
assert.equal(r.modals[0].trigger, 'Sign in');
|
|
50
|
+
assert.equal(r.modals[0].bg, 'rgb(255,255,255)');
|
|
51
|
+
assert.equal(r.modals[0].width, 400);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('handles missing snapshot fields gracefully', () => {
|
|
55
|
+
const r = extractInteractionStates({
|
|
56
|
+
hoverSamples: [{ selector: 'x', before: null, after: null }],
|
|
57
|
+
modals: [{ trigger: 'Menu' }],
|
|
58
|
+
});
|
|
59
|
+
assert.equal(r.hover.changed, 0);
|
|
60
|
+
assert.equal(r.modals[0].bg, '');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { extractModernCss } from '../src/extractors/modern-css.js';
|
|
4
|
+
|
|
5
|
+
function mkStyle(overrides = {}) {
|
|
6
|
+
return {
|
|
7
|
+
tag: 'div',
|
|
8
|
+
classList: '',
|
|
9
|
+
fontVariationSettings: 'normal',
|
|
10
|
+
fontFeatureSettings: 'normal',
|
|
11
|
+
textWrap: '',
|
|
12
|
+
textDecorationStyle: '',
|
|
13
|
+
textDecorationThickness: '',
|
|
14
|
+
textUnderlineOffset: '',
|
|
15
|
+
pseudo: null,
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('extractModernCss', () => {
|
|
21
|
+
it('returns zeroed structure for empty payload', () => {
|
|
22
|
+
const r = extractModernCss({});
|
|
23
|
+
assert.equal(r.pseudoElements.count, 0);
|
|
24
|
+
assert.equal(r.variableFonts.count, 0);
|
|
25
|
+
assert.equal(r.containerQueries.count, 0);
|
|
26
|
+
assert.deepEqual(r.envUsage, []);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('counts pseudo-elements and captures samples', () => {
|
|
30
|
+
const light = {
|
|
31
|
+
computedStyles: [
|
|
32
|
+
mkStyle({ tag: 'a', pseudo: { before: { content: '"→"', color: 'red' }, after: null } }),
|
|
33
|
+
mkStyle({ tag: 'li', pseudo: { before: { content: '"•"' }, after: { content: '"x"' } } }),
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
const r = extractModernCss({ light });
|
|
37
|
+
assert.equal(r.pseudoElements.count, 3);
|
|
38
|
+
assert.ok(r.pseudoElements.samples.length >= 2);
|
|
39
|
+
assert.equal(r.pseudoElements.samples[0].which, '::before');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('aggregates variable-font axes with min/max', () => {
|
|
43
|
+
const light = {
|
|
44
|
+
computedStyles: [
|
|
45
|
+
mkStyle({ fontVariationSettings: '"wght" 400, "slnt" 0' }),
|
|
46
|
+
mkStyle({ fontVariationSettings: '"wght" 700, "slnt" -8' }),
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
const r = extractModernCss({ light });
|
|
50
|
+
assert.equal(r.variableFonts.count, 2);
|
|
51
|
+
const wght = r.variableFonts.axes.find(a => a.axis === 'wght');
|
|
52
|
+
assert.ok(wght);
|
|
53
|
+
assert.equal(wght.min, 400);
|
|
54
|
+
assert.equal(wght.max, 700);
|
|
55
|
+
const slnt = r.variableFonts.axes.find(a => a.axis === 'slnt');
|
|
56
|
+
assert.equal(slnt.min, -8);
|
|
57
|
+
assert.equal(slnt.max, 0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('collects OpenType features and counts', () => {
|
|
61
|
+
const light = {
|
|
62
|
+
computedStyles: [
|
|
63
|
+
mkStyle({ fontFeatureSettings: '"ss01" on, "cv11"' }),
|
|
64
|
+
mkStyle({ fontFeatureSettings: '"ss01"' }),
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
const r = extractModernCss({ light });
|
|
68
|
+
const ss01 = r.openTypeFeatures.find(f => f.feature === 'ss01');
|
|
69
|
+
assert.equal(ss01.count, 2);
|
|
70
|
+
const cv11 = r.openTypeFeatures.find(f => f.feature === 'cv11');
|
|
71
|
+
assert.equal(cv11.count, 1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('collects modern text-layout properties', () => {
|
|
75
|
+
const light = {
|
|
76
|
+
computedStyles: [
|
|
77
|
+
mkStyle({ textWrap: 'balance', textDecorationStyle: 'wavy', textDecorationThickness: '2px', textUnderlineOffset: '3px' }),
|
|
78
|
+
mkStyle({ textWrap: 'balance' }),
|
|
79
|
+
mkStyle({ textWrap: 'pretty' }),
|
|
80
|
+
],
|
|
81
|
+
};
|
|
82
|
+
const r = extractModernCss({ light });
|
|
83
|
+
const balance = r.textWrap.wrap.find(w => w.value === 'balance');
|
|
84
|
+
assert.equal(balance.count, 2);
|
|
85
|
+
assert.equal(r.textWrap.decorationStyle[0].value, 'wavy');
|
|
86
|
+
assert.equal(r.textWrap.decorationThickness[0].value, '2px');
|
|
87
|
+
assert.equal(r.textWrap.underlineOffset[0].value, '3px');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('passes through container queries and env() usage', () => {
|
|
91
|
+
const light = {
|
|
92
|
+
computedStyles: [],
|
|
93
|
+
containerQueries: [
|
|
94
|
+
{ condition: '(min-width: 480px)', selectorText: '.card', declarationCount: 3 },
|
|
95
|
+
],
|
|
96
|
+
envUsage: ['safe-area-inset-top', 'safe-area-inset-top', 'viewport-segment-top'],
|
|
97
|
+
};
|
|
98
|
+
const r = extractModernCss({ light });
|
|
99
|
+
assert.equal(r.containerQueries.count, 1);
|
|
100
|
+
assert.equal(r.containerQueries.rules[0].condition, '(min-width: 480px)');
|
|
101
|
+
assert.equal(r.envUsage.length, 2);
|
|
102
|
+
assert.ok(r.envUsage.includes('safe-area-inset-top'));
|
|
103
|
+
});
|
|
104
|
+
});
|