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,100 @@
1
+ // Extractor for modern CSS features captured by the crawler:
2
+ // - Pseudo-elements (::before / ::after)
3
+ // - Variable-font axes (font-variation-settings)
4
+ // - OpenType features (font-feature-settings)
5
+ // - Modern text layout (text-wrap, text-decoration-*)
6
+ // - Container queries (@container)
7
+ // - env() usage (safe-area-inset-*, viewport-*)
8
+
9
+ export function extractModernCss(payload) {
10
+ const light = (payload && payload.light) || payload || {};
11
+ const styles = Array.isArray(light.computedStyles) ? light.computedStyles : [];
12
+
13
+ // Pseudo-elements
14
+ const pseudoSamples = [];
15
+ let pseudoCount = 0;
16
+ for (const s of styles) {
17
+ const p = s && s.pseudo;
18
+ if (!p) continue;
19
+ if (p.before) { pseudoCount++; if (pseudoSamples.length < 20) pseudoSamples.push({ tag: s.tag, classList: s.classList, which: '::before', style: p.before }); }
20
+ if (p.after) { pseudoCount++; if (pseudoSamples.length < 20) pseudoSamples.push({ tag: s.tag, classList: s.classList, which: '::after', style: p.after }); }
21
+ }
22
+
23
+ // Variable fonts
24
+ const axesMap = new Map();
25
+ let variableFontCount = 0;
26
+ for (const s of styles) {
27
+ const v = s && s.fontVariationSettings;
28
+ if (!v || v === 'normal' || v === '') continue;
29
+ variableFontCount++;
30
+ // e.g. "\"wght\" 600, \"slnt\" -4"
31
+ for (const m of String(v).matchAll(/"([^"]+)"\s+(-?\d+(?:\.\d+)?)/g)) {
32
+ const axis = m[1];
33
+ const val = parseFloat(m[2]);
34
+ if (!axesMap.has(axis)) axesMap.set(axis, { axis, min: val, max: val, count: 0 });
35
+ const a = axesMap.get(axis);
36
+ a.min = Math.min(a.min, val);
37
+ a.max = Math.max(a.max, val);
38
+ a.count++;
39
+ }
40
+ }
41
+
42
+ // OpenType features
43
+ const featMap = new Map();
44
+ for (const s of styles) {
45
+ const f = s && s.fontFeatureSettings;
46
+ if (!f || f === 'normal' || f === '') continue;
47
+ for (const m of String(f).matchAll(/"([^"]+)"(?:\s+(on|off|\d+))?/g)) {
48
+ const key = m[1];
49
+ featMap.set(key, (featMap.get(key) || 0) + 1);
50
+ }
51
+ }
52
+
53
+ // Text-wrap / decoration
54
+ const textWrapMap = new Map();
55
+ const decStyleMap = new Map();
56
+ const thicknessMap = new Map();
57
+ const offsetMap = new Map();
58
+ for (const s of styles) {
59
+ if (s.textWrap && s.textWrap !== 'wrap' && s.textWrap !== '') {
60
+ textWrapMap.set(s.textWrap, (textWrapMap.get(s.textWrap) || 0) + 1);
61
+ }
62
+ if (s.textDecorationStyle && s.textDecorationStyle !== 'solid' && s.textDecorationStyle !== '') {
63
+ decStyleMap.set(s.textDecorationStyle, (decStyleMap.get(s.textDecorationStyle) || 0) + 1);
64
+ }
65
+ if (s.textDecorationThickness && s.textDecorationThickness !== 'auto' && s.textDecorationThickness !== '') {
66
+ thicknessMap.set(s.textDecorationThickness, (thicknessMap.get(s.textDecorationThickness) || 0) + 1);
67
+ }
68
+ if (s.textUnderlineOffset && s.textUnderlineOffset !== 'auto' && s.textUnderlineOffset !== '') {
69
+ offsetMap.set(s.textUnderlineOffset, (offsetMap.get(s.textUnderlineOffset) || 0) + 1);
70
+ }
71
+ }
72
+
73
+ const containerQueries = Array.isArray(light.containerQueries) ? light.containerQueries : [];
74
+ const envUsage = Array.isArray(light.envUsage) ? light.envUsage : [];
75
+
76
+ return {
77
+ pseudoElements: {
78
+ count: pseudoCount,
79
+ samples: pseudoSamples,
80
+ },
81
+ variableFonts: {
82
+ count: variableFontCount,
83
+ axes: [...axesMap.values()].sort((a, b) => b.count - a.count),
84
+ },
85
+ openTypeFeatures: [...featMap.entries()]
86
+ .map(([feature, count]) => ({ feature, count }))
87
+ .sort((a, b) => b.count - a.count),
88
+ textWrap: {
89
+ wrap: [...textWrapMap.entries()].map(([value, count]) => ({ value, count })),
90
+ decorationStyle: [...decStyleMap.entries()].map(([value, count]) => ({ value, count })),
91
+ decorationThickness: [...thicknessMap.entries()].map(([value, count]) => ({ value, count })),
92
+ underlineOffset: [...offsetMap.entries()].map(([value, count]) => ({ value, count })),
93
+ },
94
+ containerQueries: {
95
+ count: containerQueries.length,
96
+ rules: containerQueries,
97
+ },
98
+ envUsage: [...new Set(envUsage)],
99
+ };
100
+ }
@@ -0,0 +1,65 @@
1
+ // Attribute top design tokens back to the stylesheet URL that most likely
2
+ // contributed them. Uses the per-element `sources` captured by the crawler.
3
+
4
+ import { parseColor, rgbToHex } from '../utils.js';
5
+
6
+ function firstSourceUrlWhere(styles, predicate) {
7
+ for (const s of styles) {
8
+ if (!s || !predicate(s)) continue;
9
+ const src = Array.isArray(s.sources) ? s.sources[0] : null;
10
+ if (src && src.url) return src.url;
11
+ }
12
+ return '';
13
+ }
14
+
15
+ export function extractTokenSources(design, computedStyles) {
16
+ const styles = Array.isArray(computedStyles) ? computedStyles : [];
17
+ const out = [];
18
+
19
+ // Primary color
20
+ const primaryHex = design.colors?.primary?.hex;
21
+ if (primaryHex) {
22
+ const url = firstSourceUrlWhere(styles, s => {
23
+ const p = parseColor(s.color);
24
+ return p && rgbToHex(p) === primaryHex;
25
+ });
26
+ out.push({ token: 'color.primary', path: 'colors.primary', sourceUrl: url });
27
+ }
28
+
29
+ // Text color (first in design.colors.text[])
30
+ const textHex = (design.colors?.text || [])[0];
31
+ if (textHex) {
32
+ const url = firstSourceUrlWhere(styles, s => {
33
+ const p = parseColor(s.color);
34
+ return p && rgbToHex(p) === textHex;
35
+ });
36
+ out.push({ token: 'color.text', path: 'colors.text[0]', sourceUrl: url });
37
+ }
38
+
39
+ // Body font — first typography family
40
+ const bodyFont = design.typography?.families?.[0]?.name;
41
+ if (bodyFont) {
42
+ const url = firstSourceUrlWhere(styles, s => typeof s.fontFamily === 'string' && s.fontFamily.includes(bodyFont));
43
+ out.push({ token: 'font.body', path: 'typography.families[0]', sourceUrl: url });
44
+ }
45
+
46
+ // Spacing base
47
+ const spacingBase = design.spacing?.base;
48
+ if (spacingBase != null) {
49
+ const target = `${spacingBase}px`;
50
+ const url = firstSourceUrlWhere(styles,
51
+ s => s.paddingTop === target || s.paddingLeft === target || s.marginTop === target || s.gap === target);
52
+ out.push({ token: 'spacing.base', path: 'spacing.base', sourceUrl: url });
53
+ }
54
+
55
+ // Radius base — first non-zero from design.borders.radii
56
+ const radii = design.borders?.radii || [];
57
+ const firstRadius = radii.find(r => (r.value || r) && (r.value || r) !== '0px');
58
+ if (firstRadius) {
59
+ const target = typeof firstRadius === 'string' ? firstRadius : (firstRadius.value || '');
60
+ const url = firstSourceUrlWhere(styles, s => s.borderRadius === target);
61
+ out.push({ token: 'radius.base', path: 'borders.radii[0]', sourceUrl: url });
62
+ }
63
+
64
+ return out;
65
+ }
@@ -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,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
+ });